Saltar al contenido

Documentación para desarrolladores

Última actualización: 26 de septiembre de 2026.

Todo lo que Changeloop publica para ti es JSON plano sobre HTTPS. No hay SDK que instalar, ninguna clave de API que rotar ni ningún paso de inicio de sesión: los dos feeds de abajo son lecturas públicas anónimas identificadas por tu id de feed. Sustituye YOUR_PUBLIC_ID por el tuyo en cualquier ejemplo de esta página.

Antes de empezar, algo que conviene saber: tu id de feed público está en la propia app. Inicia sesión, abre Ajustes, y está justo en la sección Feed público, en la que caes por defecto, junto con enlaces ya listos a changelog.json y roadmap.json, un enlace a tu página de feed alojada y el fragmento del widget de abajo, cada uno con su propio botón de copiar.

Primeros pasos

Cinco pasos te llevan del registro a un changelog en tu propia web. La página «Get started» de la app te guía por ellos y marca cada uno a medida que lo completas.

  1. Conecta una fuente: un repositorio de GitHub, un proyecto de GitLab o un repositorio de Bitbucket.
  2. Elige el idioma en el que se escriben tus entradas.
  3. Si quieres, crea etiquetas para que los lectores puedan filtrar por área del producto.
  4. Publica tu primera entrada. Los cambios fusionados llegan como borradores a la bandeja de revisión: aprueba uno o activa la publicación automática para ese repositorio.
  5. Llévalo a tu web: enlaza tu página alojada, pega el widget o muestra el feed JSON en tu propia página.

Tu changelog en unas diez líneas de React

Pega esto en un componente y tienes un changelog funcionando. No hace falta nada más.

import { useEffect, useState } from 'react';

const FEED = 'https://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/changelog.json';

export function Changelog() {
  const [entries, setEntries] = useState([]);
  useEffect(() => {
    fetch(FEED).then((r) => r.json()).then((feed) => setEntries(feed.data));
  }, []);
  return <ul>{entries.map((e) => <li key={e.id}><b>{e.title}</b><p>{e.mdContent}</p></li>)}</ul>;
}

mdContent es el markdown que redactamos, como texto. Si prefieres renderizar salida con formato, usa htmlContent: se construye en el servidor con nuestro propio sanitizador a partir de una lista fija de etiquetas y atributos permitidos, y es el único valor de todas estas respuestas pensado para insertarse como marcado. Todo lo demás es texto, y las entradas redactadas desde un repositorio público pueden estar influidas por cualquiera que pueda abrir una pull request allí, así que trátalas en consecuencia.

El feed del changelog

GET/v1/public/YOUR_PUBLIC_ID/changelog.json

Tus entradas publicadas, las más nuevas primero, con la id más reciente decidiendo los empates de marca de tiempo idéntica.

Parámetros de consulta

  • repos recibe una lista separada por comas de nombres completos de repositorio, por ejemplo acme/web,acme/api. Solo vuelven entradas de esos repositorios. Omítelo y recibirás todas.
  • limit es cuántas entradas quieres por página. El valor por defecto es 20, cualquier valor por encima de 50 se limita a 50, y cualquier valor que no podamos leer como un número positivo cae a 20 en lugar de fallar.
  • cursor es opaco. Toma el valor nextCursor de la respuesta anterior y devuélvelo tal cual. Un cursor que no podemos decodificar se trata como si no hubiera cursor, así que obtienes la primera página de nuevo en vez de un error.

Respuesta

{
  "data": [
    {
      "id": "66b0c1f2e4a9d1c3b5a70011",
      "title": "Saved views on the inbox",
      "mdContent": "You can now pin a filter and come back to it.",
      "htmlContent": "<p>You can now pin a filter and come back to it.</p>",
      "repoFullName": "acme/web",
      "category": "feature",
      "tags": ["Inbox"],
      "learnMoreUrl": "https://acme.example/docs/saved-views",
      "publishedAt": "2026-08-06T09:12:44.000Z"
    }
  ],
  "nextCursor": null,
  "tagColors": { "Inbox": "#4f46e5" }
}

Cada entrada lleva las mismas nueve claves: id, title, mdContent, htmlContent, repoFullName, category, tags, learnMoreUrl y publishedAt. category es feature, fix o internal, y es null cuando quien redactó no puso ninguna, publishedAt es una cadena ISO 8601, y htmlContent es una cadena vacía en una entrada que nunca pasó por el redactor. tags es un array con los nombres de tus propias áreas de producto y está vacío si no se asignó ninguna, learnMoreUrl es null salvo que alguien lo añadiera en la revisión, y el color de cada etiqueta viene del mapa tagColors de la respuesta, no de la entrada, así que una etiqueta que hayas retirado de tu vocabulario simplemente se renderiza sin color. nextCursor es null cuando has llegado al final.

Un id de feed desconocido responde 404 con {"error":"not_found"}, y también uno mal formado. Las dos situaciones son indistinguibles a propósito, así que este endpoint no sirve para averiguar qué ids existen.

El feed del roadmap

GET/v1/public/YOUR_PUBLIC_ID/roadmap.json

Las mismas tres columnas que tu equipo mantiene a mano.

{
  "columns": [
    { "column": "planned", "items": [], "hasMore": false },
    {
      "column": "building",
      "items": [
        {
          "id": "66b0c1f2e4a9d1c3b5a70042",
          "column": "building",
          "publicTitle": "Slack notifications",
          "publicDescription": "Post each published entry to a channel you pick.",
          "publishedAt": "2026-08-05T16:20:01.000Z"
        }
      ],
      "hasMore": false
    },
    { "column": "shipped", "items": [], "hasMore": false }
  ]
}

columns es un array, no un objeto indexado por nombre de columna, y su orden forma parte del contrato: planned, luego building, luego shipped. Las tres están siempre presentes, incluidas las vacías, así que nunca tienes que distinguir «esta columna no existe» de «aún no tiene nada». Renderízalas en el orden recibido y coincidirás con cualquier otra superficie que construyamos.

Un item tiene exactamente cinco claves: id, column, publicTitle, publicDescription y publishedAt. publicDescription es siempre una cadena y puede estar vacía, nunca null. Nada sobre el issue del que procede un item se expone aquí, ni el repositorio ni el número de issue, y eso es intencionado, no un descuido que rellenaremos más adelante.

Este endpoint no acepta ningún parámetro de consulta. No hay cursor, ni limit ni filtro de repositorio, porque una roadmap es un tablero pequeño que cura una persona, no un registro que crece sin fin. Cada columna devuelve hasta 50 items y marca hasMore si había más. hasMore es informativo: no hay cursor con el que seguirlo, así que no construyas un paginador alrededor de esto.

publicTitle y publicDescription son texto plano redactado a partir de títulos y cuerpos de issues, que en un repositorio público puede influir cualquiera que abra un issue. No llevan garantía de saneado de HTML y no son la excepción de htmlContent. Renderízalos como texto.

El widget incrustable

Si prefieres no construir nada, añade estas dos líneas. El widget es un custom element que renderiza en un shadow root, así que ni hereda tus estilos ni se filtra en ellos.

<script src="https://api.changeloop.dev/widget.js" defer></script>
<changelogapp-widget
  data-public-id="YOUR_PUBLIC_ID"
  data-api="https://api.changeloop.dev"></changelogapp-widget>

Ambos atributos son obligatorios. data-public-id es tu id de feed, data-api es el origen desde el que carga el widget. Si falta alguno, el elemento escribe un error en la consola y no renderiza nada, que es lo primero que hay que comprobar si ves un espacio vacío donde debería estar.

Añade data-theme="dark" al elemento para renderizarlo en oscuro; tu página puede alternarlo en tiempo de ejecución. Para un estilo más profundo, el widget expone propiedades CSS personalizadas (--changelogapp-text, --changelogapp-bg, --changelogapp-accent y más) y nombres ::part(), que defines en tu propia hoja de estilos. La app previsualiza ambos temas en vivo en Ajustes, Feed público.

Añade data-repos para mostrar solo algunos de tus repositorios, por ejemplo el changelog de un producto en el sitio de ese producto cuando varios productos comparten una cuenta. El valor es una lista separada por comas de nombres completos, owner/repo; un nombre sin propietario no coincide con nada y muestra un feed vacío sin error. Se tienen en cuenta hasta diez repositorios. Un widget acotado muestra solo Updates y Feedback, porque el roadmap no tiene vista por repositorio, y el feedback se sigue archivando donde apunta el destino de feedback de tu equipo. En Ajustes, Feed público hay un selector que escribe el atributo por ti.

Renderiza tres pestañas en este orden: Novedades, Roadmap y Feedback. Las dos primeras leen los feeds de arriba. La tercera envía al endpoint de abajo y guarda cada id de envío en localStorage, así que quien lo visita puede volver y ver qué pasó con lo que envió.

El script se sirve versionado. /widget.js siempre sirve el build más nuevo y se cachea una hora, así que un release llega a tus visitantes sin que toques nada. /widget-vN.js fija un build: una vez servido un número de versión, sus bytes nunca vuelven a cambiar, y se cachea un año. Fíjala si prefieres adoptar los cambios a propósito.

Carga exactamente un script del widget por página

Las dos URL son alternativas, no capas. Ambas registran el mismo nombre de custom element, y un navegador solo deja registrar un nombre una vez por documento: gana el script que se ejecute primero, durante toda la vida de la página, y el segundo queda inerte. Así que una página con /widget.js y /widget-v5.js a la vez renderiza lo que el navegador ejecutara primero por azar, algo que no controlas; añadir /widget-v5.js junto a un /widget.js existente para fijar la versión no hace nada. Suele ganar el build más antiguo, porque ya está en caché.

Cuando pasa esto, el widget escribe un aviso en la consola nombrando ambos builds, así que no tienes que adivinar. No puede hacer más que avisar: cuando corre la segunda copia, la primera ya se ha quedado con el nombre. La solución es siempre sustituir la etiqueta script en vez de añadir otra, y lo mismo si un gestor de etiquetas o un parcial te inyecta una. Para pasar del build continuo a uno fijado, cambia el src.

La página de feed alojada

https://feed.changeloop.dev/feed/YOUR_PUBLIC_ID

En esa misma dirección alojamos también una página sencilla: tu changelog y tu tablero de roadmap, renderizados desde los mismos dos feeds de arriba. No necesita inicio de sesión ni ninguna configuración por tu parte. También es adonde devolvemos a la gente cuando se cierra un ciclo: el comentario Shipped que dejamos en un issue de GitHub enlaza aquí, y también shippedEntry.link desde la consulta de estado de arriba, ambos llegando a la entrada publicada con su propio ancla #entry-ID, que sigue encontrando la entrada aunque ya haya pasado a una página posterior.

Trátala como una alternativa, no como la integración. El feed del changelog y el widget siguen siendo la forma de meter esto en tu propio sitio para que parezca tu producto y no el nuestro; esta página es para mientras no lo hayas hecho, y para los enlaces de cierre de ciclo, que apuntan aquí sin importar qué más hayas construido.

Tu propio dominio

Puedes servir la página alojada desde tu propia dirección, sin cambios de DNS ni de certificados. En Ajustes, Dominio propio, pega la dirección pública que verán tus lectores (por ejemplo https://example.com/changelog) y luego apunta esa ruta de tu sitio al destino del proxy que se muestra allí: una sola regla cubre la página, sus assets, sus datos y sus feeds. Comprobar mi dominio consulta tu dirección desde nuestro lado y te dice si el proxy está bien y, si no, qué cambiar.

El servidor MCP

POSThttps://api.changeloop.dev/mcp

Si trabajas en Claude Code, ChatGPT u otro agente que hable Model Context Protocol, puedes conectarlo directamente a tu changelog. El agente puede entonces ver qué espera revisión, editar el texto y publicar, sin que salgas del editor. Es la misma puerta de revisión que en la app web: nada se hace público hasta que algo lo aprueba.

Conectar Claude Code

Crea primero una clave de API (Ajustes, Claves de API) y luego añade el servidor con tu clave en la cabecera:

claude mcp add --transport http changeloop \
  https://api.changeloop.dev/mcp \
  --header "Authorization: Bearer clapi_YOUR_KEY"

Para un cliente que en su lugar lee una configuración JSON, lo mismo se ve así:

{
  "mcpServers": {
    "changeloop": {
      "type": "http",
      "url": "https://api.changeloop.dev/mcp",
      "headers": { "Authorization": "Bearer clapi_YOUR_KEY" }
    }
  }
}

Todavía no hay un flujo OAuth. La autenticación es la clave de API en la cabecera, que es justo lo que hacen los dos comandos de arriba. Revocar esa clave en Ajustes desconecta el agente en su siguiente petición.

Qué puede hacer el agente

Siete herramientas, y la lista es deliberadamente corta. Todo lo demás que puede hacer este producto es accesible por la API REST con la misma clave; cada herramienta expuesta a un agente es una cosa más a la que se le puede convencer de llamar.

  • list_pending_entries, list_published_entries, get_entry - leen tus entradas. Las pendientes no son públicas.
  • update_entry - cambia el título o el cuerpo en markdown de una entrada. El HTML que sirve el feed se regenera desde tu markdown con nuestro sanitizador; un agente no puede aportar HTML.
  • approve_entry - publica. Es público e inmediato, y notifica a cualquier feedback vinculado en GitHub. Solo puede aprobarse una entrada pendiente.
  • discard_entry - mantiene una entrada fuera del changelog. Reversible desde la app web.
  • get_changelog_info - tu id de feed y las direcciones desde las que se sirve tu changelog.

Qué no puede hacer

Cada herramienta está limitada al equipo al que pertenece la clave, y ninguna acepta un equipo como argumento, así que no hay nada con lo que apuntar a otro equipo aunque alguien lo intentara. El servidor no acepta una sesión de navegador, solo una clave: una petición tiene que adjuntar la credencial a propósito. Y una clave no puede gestionar claves ni descargar tu exportación de datos, así que un agente conectado así no puede crearse una segunda credencial ni sacar tus datos en una sola llamada.

Claves de API

Todo lo anterior es anónimo y no necesita credencial. La API autenticada, tus ajustes y tu bandeja de revisión, es una superficie distinta, y acepta una sesión de navegador autenticada o una clave de API. Las claves son para scripts y agentes: cualquier cosa que tenga que llegar a tu changelog sin una persona delante del teclado.

Authorization: Bearer clapi_YOUR_KEY

Créala en la app, en Ajustes, en la pestaña Claves de API. La clave se muestra una vez, en el momento en que la creas, y nunca más: solo guardamos un hash de ella, así que no hay ninguna pantalla que pueda mostrártela una segunda vez. Si la pierdes, revócala y crea otra.

Qué puede y qué no puede hacer una clave

Una clave lleva el mismo acceso que iniciar sesión, limitado al único equipo en el que se creó, con dos excepciones deliberadas. No puede gestionar claves de API, y no puede descargar tu exportación de datos. Ambas cosas exigen un inicio de sesión real, para que una clave filtrada no pueda crearse un sustituto, no pueda revocar las claves con las que la bloquearías, y no pueda sacar los datos de tu equipo en una sola petición.

Revocar

Una revocación surte efecto en la siguiente petición. Una clave revocada responde 401 igual que una desconocida, y sigue respondiendo 401 aunque el navegador aún tenga una sesión válida, porque una petición con cabecera Authorization nunca se reintenta en silencio como petición con cookie. La clave revocada sigue listada con la fecha de revocación y la de último uso, que es justo lo que necesitas al averiguar qué alcanzó una clave filtrada.

Planes y límites

El plan gratuito redacta 20 cambios fusionados al mes y limita a 50 diarios el número de fusiones revisadas, envíos de feedback clasificados, tarjetas de roadmap redactadas y versiones alternativas; el plan de equipo no tiene límites estrictos. Ajustes, Plan y uso muestra cada presupuesto tal como el propio producto lo cuenta, con el momento en que se reinicia, antes de que se rechace nada. El trabajo que llega por encima de un límite se retiene, no se pierde: una entrada por encima de la cuota espera en la bandeja y un borrador de roadmap rechazado se puede reintentar cuando la ventana se renueve.

GitLab y Bitbucket

Un proyecto de GitLab o un repositorio de Bitbucket puede alimentar tu changelog igual que un repositorio de GitHub: conéctalo en Ajustes, luego GitLab, o en Ajustes, luego Bitbucket, añade el webhook que te damos (o, en bitbucket.org, deja que Connect with Bitbucket lo añada si la página de Bitbucket ofrece ese botón), y cada cambio fusionado en la rama que indiques se convierte en una entrada de borrador en tu bandeja de revisión, escrita de la misma forma y sujeta a la misma revisión humana. Las entradas salen de pull requests o merge requests fusionadas o, en GitHub y Bitbucket, de los pushes si eliges el modo push en Ajustes, luego What creates drafts. Los proyectos de GitLab generan borradores solo a partir de merge requests.

Conectar un proyecto

Los proyectos de GitLab se conectan en Ajustes, luego GitLab, y los repositorios de Bitbucket en Ajustes, luego Bitbucket. Introduce la ruta (en GitLab, grupo y proyecto, como acme/web; en Bitbucket, workspace y repositorio, como acme/app) y te devolvemos una dirección de webhook y un secreto. Pega ambos en los ajustes de webhook del otro lado: en GitLab marca Merge request events, en Bitbucket marca los disparadores Merged pull request y Push repository. Las instancias autoalojadas funcionan, por https. El secreto se muestra una vez, en ese momento. Si lo pierdes, elimina el proyecto y vuelve a conectarlo. En bitbucket.org, si la página de Bitbucket muestra un botón Connect with Bitbucket, puedes saltarte el pegado: púlsalo, permite el acceso una vez y leemos la rama principal del repositorio y añadimos el webhook por ti. Necesitas permisos de administrador en el repositorio. Con Bitbucket autoalojado, o si prefieres pegar, elige Set it up by hand y obtendrás la dirección y el secreto como arriba. Si eliminas un repositorio de Bitbucket y lo vuelves a conectar, borra también su webhook anterior en Bitbucket, en Repository settings, luego Webhooks. Una vez conectado un proyecto, puedes cambiar su rama y activar la publicación automática en su fila, y si una entrega se ignoró, la fila explica por qué.

Por qué Bitbucket pide una rama y GitLab no

GitLab nos dice qué rama trata tu proyecto como predeterminada, así que puedes dejar el campo vacío y que signifique eso. Bitbucket no envía ninguna rama predeterminada, así que si te dejáramos dejarlo vacío no tendríamos con qué comparar, y tu webhook se quedaría con pinta de perfectamente instalado sin producir jamás una sola entrada. Preferimos hacerte una pregunta antes que dejar que eso pase. Con Connect with Bitbucket le preguntamos a Bitbucket por la rama principal cuando permites el acceso, así que no tienes que escribirla.

Qué no cubren todavía

Entradas de changelog, y nada más. Que el widget de feedback te abra un issue, que la respuesta se publique en ese issue cuando se publique el fix, que la roadmap pública se alimente de etiquetas de issues, y la vista previa de origen en la bandeja de revisión, todo eso es hoy exclusivo de GitHub.

Preferimos decir el motivo antes que disimularlo. Cada una de esas cosas necesita un token de acceso con permiso de escritura en tu proyecto, que quedaría en nuestro poder. Las entradas de changelog no necesitan ninguno, porque todo aquello de lo que se escriben llega en el propio webhook, así que conectar GitLab o Bitbucket mediante el webhook no nos da ninguna credencial ni ningún acceso de lectura a tu código. Connect with Bitbucket es la única excepción. Bitbucket nos presta, para una sola petición, un token que puede leer el repositorio y sus pull requests y gestionar sus webhooks, que usamos solo para leer la rama principal y añadir el webhook, y después descartamos. No se guarda nada. Preferimos entregar la parte que no te cuesta nada antes que pedir un token para redondear una lista de funciones.

Otras versiones de una entrada

Un cambio suele explicarse más de una vez: a los clientes en el changelog, a quien responda preguntas sobre él, y en un canal donde nadie lee cuatro párrafos. Desde la bandeja de revisión puedes redactar dos versiones extra de una entrada antes de aprobarla.

Una versión de anuncio es de una o dos líneas, y es lo que se publica en Slack al aprobar la entrada, en lugar del texto completo. Una nota de soporte es un briefing interno: qué cambió, qué notarán los clientes, y una frase que alguien de soporte podría decir casi textualmente. Ambas son borradores que puedes reescribir antes de usarlos, y ambas se pueden eliminar.

Ninguna de las dos se publica

Estas versiones nunca aparecen en tu página de changelog, en ningún feed, en el widget ni en la API que los sirve. La nota de soporte en particular está escrita para personas de tu empresa y puede ser más directa que la propia entrada. Solo existe en tu bandeja de revisión y, si las usas, en tu propia copia.

De qué se escriben

Siempre de la entrada, nunca de la pull request. Eso es intencionado: la entrada ya ha pasado por la regla que mantiene vagos los fixes de seguridad, y por tu propia revisión. Una versión reescrita a partir de ella no puede reintroducir un detalle que quitaste, porque ese detalle no está en lo que recibió el modelo.

Anunciar en Slack

Aprueba una entrada y puede publicarse en un canal de Slack en el mismo momento en que se hace pública. Conéctalo en Ajustes, en la pestaña Slack: crea un webhook entrante en tu propio workspace, elige el canal y pega la URL. Más allá de ese webhook no se instala nada en tu lado, y no pedimos acceso a tu workspace.

El mensaje lleva el título de la entrada, el texto tal como lo aprobaste, su categoría y etiquetas, y un enlace de vuelta a la entrada en tu changelog. El markdown se traduce a lo que Slack realmente renderiza, así que una entrada no llega mostrando sus propios asteriscos.

La URL del webhook es una credencial

Quien tenga esa URL puede publicar en el canal, así que la tratamos como una contraseña: se guarda, y después ninguna pantalla ni respuesta de API vuelve a mostrarla, tampoco tu propia exportación de datos. Lo que ves después es una máscara, suficiente para distinguir dos webhooks e inútil para cualquier otra persona. Solo aceptamos una dirección hooks.slack.com, así que una URL mal escrita o sustituida se rechaza en lugar de consultarse.

Cuando deja de funcionar

Si eliminas la app en Slack o archivas el canal, el webhook deja de funcionar de forma permanente. Lo detectamos en el primer mensaje rechazado, desactivamos los anuncios y lo indicamos en la pestaña Slack con el motivo y la fecha. Que no sigamos reintentando en silencio es intencionado: un changelog que nadie anunció se ve exactamente igual que uno que nadie leyó, y esa diferencia merece comunicarse.

Pausar

Pausar detiene los anuncios y conserva el webhook, así que reanudar es un clic en vez de otro paso por Slack. Desconectar elimina la URL por completo. En ambos casos, publicar en sí no se ve afectado: Slack es un canal en el que publica tu changelog, nunca una puerta que espera. Si Slack no está disponible cuando apruebas algo, la entrada se publica igualmente y el anuncio se reintenta por sí solo.

RSS y JSON Feed

GEThttps://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/rss.xmlGEThttps://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/feed.json

Las mismas entradas publicadas como feed suscribible, en los dos formatos que entienden los lectores: RSS 2.0 y JSON Feed 1.1. Ambos aceptan los mismos filtros repos, category y tag que el feed del changelog y llevan el mismo Cache-Control y ETag. Ninguno pagina: un lector consulta la cabeza del feed, así que estos devuelven solo las entradas más recientes, sin cursor.

El texto de la entrada es el HTML saneado, envuelto en CDATA para RSS y como content_html para JSON Feed. JSON Feed lleva además los colores de tus etiquetas bajo una extensión con espacio de nombres _changelogapp; RSS no, porque ningún lector los pintaría.

La página alojada anuncia ambos como enlaces rel="alternate", así que un navegador o lector que llegue allí puede suscribirse sin que le digan las rutas.

Una entrada por sí sola

GEThttps://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/entries/ENTRY_ID

Devuelve una única entrada publicada, el mismo objeto que lleva el feed del changelog en su array data. Es a donde apuntan los permalinks de los feeds, y es útil cuando tienes una id y no quieres paginar el feed para encontrarla. Una id desconocida, o de una entrada no publicada, devuelve 404 con el mismo cuerpo que cualquier otra id desconocida.

El feed en markdown

GEThttps://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/changelog.md

Las mismas entradas publicadas como markdown plano, servido como text/markdown. Existe para lectores que no son navegadores: un LLM o un agente que responde qué ha cambiado recientemente en este producto recibe el texto sin analizar RSS ni recorrer JSON. Acepta los mismos filtros repos, category y tag que el feed del changelog, lleva el mismo Cache-Control y ETag, y responde 304 a una petición condicional igual que los otros dos.

Cada entrada es una sección: el título como encabezado, luego una sola línea con fecha, categoría y etiquetas, luego el texto de la entrada tal como se escribió, luego el enlace Learn more si la entrada tiene uno, luego su permalink. El documento empieza con el título y la descripción de tu feed y enlaza de vuelta a la página alojada. Si aún no hay nada publicado, lo dice en una frase en vez de devolver un cuerpo vacío, para que quien lea pueda distinguirlo de un fallo de conexión.

La página alojada lo anuncia como un enlace rel="alternate" con type text/markdown, junto a los enlaces de RSS y JSON Feed, así que un agente que haya cargado el HTML puede encontrarlo sin que le digan la ruta.

Lo que sirve es el markdown que redactamos y tú aprobaste, no el HTML saneado. Eso es seguro como markdown, que es inerte, y por eso esta respuesta nunca es text/html. Si lo renderizas tú mismo, escápalo como escaparías cualquier otro markdown no confiable: las entradas redactadas desde un repositorio público pueden estar influidas por cualquiera que pueda abrir una pull request allí.

Recoger feedback en tu propia web

Añade tus orígenes antes de probar esto

Este es el único endpoint del producto que escribe, así que no acepta peticiones de cualquier sitio. Compara la cabecera Origin del navegador contra una lista blanca por equipo, y esa lista empieza vacía. Vacía significa rechazar todo, no permitir todo. Hasta que añadas el origen donde incrustas, cada envío vuelve con 403 y {"error":"origin_not_allowed"}, y nada llega a tu bandeja. Si tu formulario parece correcto y aun así falla, casi siempre es esto. Fija la lista con un PATCH autenticado a /v1/settings/feed con {"allowedOrigins": ["https://your-site.example"]}, y léela de vuelta con un GET a la misma ruta, que responde con tu publicId, tus allowedOrigins, y el feedTitle y feedDescription que ven tus suscriptores en un lector de feeds. Guardamos cada origen exactamente en la forma en que lo envía un navegador, así que una barra final o un puerto por defecto explícito en lo que envíes no es problema.

POST/v1/public/YOUR_PUBLIC_ID/feedback
POST https://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/feedback
Content-Type: application/json
Origin: https://your-site.example

{ "email": "someone@example.com", "message": "Dark mode, please." }

202 Accepted
{ "publicSubmissionId": "0ZbQ8yqk3n7T1sVJ4mWpLd2rXfEuGh6A" }

email debe parecer una dirección de correo y tener 254 caracteres o menos. message no puede estar vacío y debe pesar 2 KB o menos, medido en bytes UTF-8, no en caracteres. El cuerpo JSON completo está limitado a 8 KB. Hay un campo más, website: es un honeypot, así que omítelo, o envíalo vacío si lo renderizas como un campo oculto, tal como hace nuestro widget.

Conviene entender el honeypot antes de depurar nada con él. Si website llega con contenido, respondemos 202 con un id de envío de aspecto perfectamente normal y luego no hacemos nada en absoluto, porque un bot que aprende que lo pillaron simplemente lo intenta de otra forma. Esa es la respuesta correcta para un bot y confusa para ti, así que si tu propio formulario tiene un campo llamado website que un navegador podría autorrellenar, renómbralo u omítelo. Un envío que parece aceptado y nunca aparece casi siempre es esto.

Un envío que aceptamos devuelve 202 con un publicSubmissionId. Devuélveselo a quien lo envió y guárdalo si puedes: es la única forma de que consulte qué pasó después.

Los casos de fallo son 400 con invalid_email o invalid_message por forma incorrecta, 413 con email_too_large o message_too_large por forma correcta pero demasiado grande, 429 con rate_limited al superar 5 envíos por minuto o 30 por hora desde una dirección contra un feed, 403 con origin_not_allowed, y 404 con not_found para un id de feed que no reconocemos.

También hay un tope diario por equipo sobre cuánto trabajo posterior pueden desencadenar los envíos. Por encima de él seguimos aceptándolo y guardándolo todo, solo que espera a que alguien de tu equipo lo revise en vez de abrir algo por sí solo.

Consultar un envío

GET/v1/public/YOUR_PUBLIC_ID/feedback/PUBLIC_SUBMISSION_ID

Responde con status, además de githubIssueUrl en cuanto existe un issue para ese envío, y shippedEntry con título y enlace en cuanto el trabajo está publicado. La dirección de correo de quien envió nunca se lee de nuestra base de datos para esta ruta, y mucho menos se devuelve, y eso es lo que hace segura la respuesta para renderizarla en una página que puede ver cualquiera. La id es toda la credencial, trátala como tal. Está limitada a 20 peticiones por minuto y 200 por hora por dirección y feed.

Caché, CORS y peticiones condicionales

Ambos feeds envían Cache-Control: public, max-age=60, stale-while-revalidate=300 junto con un ETag fuerte. Devuelve ese ETag como If-None-Match y un feed sin cambios responde 304 sin cuerpo. Ningún campo de la respuesta lleva un valor de reloj, así que el ETag permanece estable cuando volvemos a renderizar datos sin cambios, y por eso puede uno fiarse de esos 304.

Los dos feeds y la consulta de envío son lecturas anónimas y responden con Access-Control-Allow-Origin: *, así que puedes llamarlos desde cualquier origen, desde curl o desde un paso de build. El POST de feedback es la excepción: responde con tu propio origen permitido y un Vary: Origin, nunca con un comodín. Los navegadores le hacen preflight, y un preflight siempre responde 204 tanto si el origen está permitido como si no, así que no sirve para sondear tus ajustes.