Documentation développeurs
Dernière mise à jour : 26 septembre 2026.
Tout ce que Changeloop publie pour toi est du JSON simple en HTTPS. Aucun SDK à installer, aucune clé API à faire tourner, aucune étape de connexion : les deux flux ci-dessous sont des lectures publiques anonymes identifiées par ton id de flux. Remplace YOUR_PUBLIC_ID par le tien dans tous les exemples de cette page.
Une chose à savoir avant de commencer : ton id de flux public se trouve dans l'application elle-même. Connecte-toi, ouvre Paramètres, et il est juste là dans la section Flux public, celle sur laquelle tu atterris par défaut, avec des liens déjà prêts vers changelog.json et roadmap.json, un lien vers ta page de flux hébergée et l'extrait du widget ci-dessous, chacun avec son propre bouton de copie.
Bien démarrer
Cinq étapes te mènent de l'inscription à un changelog sur ton propre site. La page « Get started » de l'application t'accompagne à chaque étape et coche chacune dès qu'elle est faite.
- Connecte une source : un dépôt GitHub, un projet GitLab ou un dépôt Bitbucket.
- Choisis la langue dans laquelle tes entrées sont rédigées.
- Si tu veux, crée des étiquettes pour que les lecteurs puissent filtrer par partie du produit.
- Publie ta première entrée. Les changements fusionnés arrivent sous forme de brouillons dans la boîte de relecture : approuves-en un, ou active la publication automatique pour ce dépôt.
- Mets-le sur ton site : ajoute un lien vers ta page hébergée, colle le widget ou affiche le flux JSON dans ta propre page.
Ton changelog en une dizaine de lignes de React
Colle ça dans un composant et tu as un changelog fonctionnel. Rien d'autre à ajouter.
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 est le markdown que nous avons rédigé, sous forme de texte. Si tu préfères rendre une sortie formatée, utilise htmlContent : il est construit côté serveur par notre propre outil de nettoyage à partir d'une liste fixe de balises et d'attributs autorisés, et c'est la seule valeur de toutes ces réponses conçue pour être insérée comme balisage. Tout le reste est du texte, et les entrées rédigées depuis un dépôt public peuvent être influencées par quiconque peut y ouvrir une pull request, traite-les donc en conséquence.
Le flux du changelog
GET/v1/public/YOUR_PUBLIC_ID/changelog.jsonTes entrées publiées, de la plus récente à la plus ancienne, l'id la plus récente départageant les égalités d'horodatage identique.
Paramètres de requête
- repos accepte une liste séparée par des virgules de noms complets de dépôts, par exemple acme/web,acme/api. Seules les entrées de ces dépôts reviennent. Omets-le et tu les obtiendras toutes.
- limit est le nombre d'entrées voulues par page. La valeur par défaut est 20, toute valeur au-dessus de 50 est limitée à 50, et toute valeur que nous ne pouvons pas lire comme un nombre positif retombe à 20 au lieu d'échouer.
- cursor est opaque. Prends la valeur nextCursor de la réponse précédente et renvoie-la telle quelle. Un curseur que nous ne pouvons pas décoder est traité comme l'absence de curseur, tu obtiens donc la première page à nouveau plutôt qu'une erreur.
Réponse
{
"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" }
}
Chaque entrée porte les neuf mêmes clés : id, title, mdContent, htmlContent, repoFullName, category, tags, learnMoreUrl et publishedAt. category vaut feature, fix ou internal, et est null quand la personne qui a rédigé n'en a défini aucune, publishedAt est une chaîne ISO 8601, et htmlContent est une chaîne vide sur une entrée jamais passée par le rédacteur. tags est un tableau des noms de tes propres zones de produit et est vide si aucune n'a été assignée, learnMoreUrl est null sauf si quelqu'un l'a ajouté en relecture, et la couleur de chaque étiquette vient de la carte tagColors de la réponse, pas de l'entrée, donc une étiquette que tu as depuis retirée de ton vocabulaire est simplement rendue sans couleur. nextCursor est null quand tu as atteint la fin.
Un id de flux inconnu répond 404 avec {"error":"not_found"}, tout comme un id malformé. Les deux cas sont volontairement indiscernables, donc cet endpoint ne permet pas de découvrir quels id existent.
Le flux de la roadmap
GET/v1/public/YOUR_PUBLIC_ID/roadmap.jsonLes trois mêmes colonnes que ton équipe maintient à la main.
{
"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 est un tableau, pas un objet indexé par nom de colonne, et son ordre fait partie du contrat : planned, puis building, puis shipped. Les trois sont toujours présentes, y compris vides, tu n'as donc jamais à distinguer « cette colonne n'existe pas » de « elle n'a encore rien ». Rends-les dans l'ordre reçu et tu correspondras à toute autre surface que nous construisons.
Un item a exactement cinq clés : id, column, publicTitle, publicDescription et publishedAt. publicDescription est toujours une chaîne et peut être vide, jamais null. Rien concernant l'issue d'où vient un item n'est exposé ici, ni le dépôt ni le numéro de l'issue, et c'est délibéré, pas un oubli qu'on comblera plus tard.
Cet endpoint n'accepte aucun paramètre de requête. Pas de curseur, pas de limit, pas de filtre de dépôt, car une roadmap est un petit tableau qu'une personne organise, pas un journal qui grandit indéfiniment. Chaque colonne renvoie jusqu'à 50 items et définit hasMore s'il y en avait plus. hasMore est informatif : il n'y a pas de curseur pour le suivre, ne construis donc pas de pagination autour.
publicTitle et publicDescription sont du texte simple rédigé à partir de titres et de corps d'issues, que sur un dépôt public quiconque peut influencer en ouvrant une issue. Ils ne portent aucune garantie de nettoyage HTML et ne sont pas l'exception htmlContent. Rends-les comme du texte.
Le widget intégrable
Si tu préfères ne rien construire, ajoute ces deux lignes. Le widget est un custom element qui se rend dans un shadow root, il n'hérite donc ni ne fuit dans tes styles.
<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>
Les deux attributs sont obligatoires. data-public-id est ton id de flux, data-api est l'origine depuis laquelle le widget charge. Si l'un des deux manque, l'élément écrit une erreur dans la console et ne rend rien du tout, ce qui est la première chose à vérifier si tu vois un espace vide là où il devrait être.
Ajoute data-theme="dark" à l'élément pour un rendu sombre ; ta page peut le basculer à l'exécution. Pour un style plus poussé, le widget expose des propriétés CSS personnalisées (--changelogapp-text, --changelogapp-bg, --changelogapp-accent et d'autres) et des noms ::part(), que tu définis dans ta propre feuille de style. L'application prévisualise les deux thèmes en direct dans Paramètres, Flux public.
Ajoutez data-repos pour n'afficher que certains de vos dépôts, par exemple le changelog d'un produit sur le site de ce produit lorsque plusieurs produits partagent un même compte. La valeur est une liste de noms complets owner/repo séparés par des virgules ; un nom sans propriétaire ne correspond à rien et affiche un flux vide sans erreur. Dix dépôts au maximum sont pris en compte. Un widget ainsi restreint n'affiche que Updates et Feedback, car la roadmap n'a pas de vue par dépôt, et les retours sont toujours déposés là où pointe la cible de feedback de votre équipe. Réglages, Flux public propose un sélecteur qui écrit l'attribut pour vous.
Il rend trois onglets dans cet ordre : Nouveautés, Roadmap et Feedback. Les deux premiers lisent les flux ci-dessus. Le troisième envoie à l'endpoint ci-dessous et conserve chaque id d'envoi dans localStorage, pour que le visiteur puisse revenir voir ce qu'il est advenu de son message.
Le script est servi avec versionnage. /widget.js sert toujours la build la plus récente et est mis en cache une heure, donc une version atteint tes visiteurs sans que tu touches à rien. /widget-vN.js fige une build : une fois un numéro de version servi, ses octets ne changent plus jamais, et il est mis en cache un an. Fige-le si tu préfères adopter les changements délibérément.
Charge exactement un script de widget par page
Les deux URL sont des alternatives, pas des couches. Les deux enregistrent le même nom de custom element, et un navigateur ne permet d'enregistrer un nom qu'une seule fois par document : le script qui s'exécute en premier gagne, pour toute la vie de la page, et le second reste inerte. Donc une page portant à la fois /widget.js et /widget-v5.js rend ce que le navigateur a exécuté en premier par hasard, ce que tu ne contrôles pas ; ajouter /widget-v5.js à côté d'un /widget.js existant pour figer la version ne fait rien du tout. C'est généralement la build la plus ancienne qui gagne, car elle est déjà en cache.
Quand ça arrive, le widget écrit un avertissement dans la console nommant les deux builds, tu n'as donc pas à deviner. Il ne peut rien faire de plus qu'avertir : quand la deuxième copie s'exécute, la première a déjà pris le nom. La solution est toujours de remplacer la balise script au lieu d'en ajouter une autre, et pareil si un gestionnaire de balises ou un partiel t'en injecte une. Pour passer de la build continue à une figée, change le src.
La page de flux hébergée
https://feed.changeloop.dev/feed/YOUR_PUBLIC_IDÀ cette même adresse, nous hébergeons aussi une page simple : ton changelog et ton tableau de roadmap, rendus depuis les deux mêmes flux ci-dessus. Elle ne nécessite ni connexion ni aucune configuration de ta part. C'est aussi là que nous renvoyons les gens une fois qu'une boucle se referme : le commentaire Shipped que nous laissons sur une issue GitHub renvoie ici, tout comme shippedEntry.link depuis la vérification de statut ci-dessus, tous deux atterrissant sur l'entrée livrée avec sa propre ancre #entry-ID, qui retrouve l'entrée même si elle est depuis passée à une page ultérieure.
Traite-la comme une solution de repli, pas comme l'intégration. Le flux du changelog et le widget restent la façon d'intégrer ça sur ton propre site pour qu'il ressemble à ton produit et pas au nôtre ; cette page sert pour la période avant que tu l'aies fait, et pour les liens de fermeture de boucle, qui pointent ici quoi que tu aies construit d'autre.
Ton propre domaine
Tu peux servir la page hébergée depuis ta propre adresse, sans modification de DNS ni de certificat. Dans Paramètres, Domaine personnalisé, colle l'adresse publique que verront tes lecteurs (par exemple https://example.com/changelog), puis fais pointer ce chemin de ton site vers la cible du proxy indiquée : une seule règle couvre la page, ses assets, ses données et ses flux. Vérifier mon domaine récupère ton adresse depuis notre côté et te dit si le proxy est correct et, sinon, quoi changer.
Le serveur MCP
POSThttps://api.changeloop.dev/mcpSi tu travailles dans Claude Code, ChatGPT ou un autre agent qui parle le Model Context Protocol, tu peux le connecter directement à ton changelog. L'agent peut alors voir ce qui attend une relecture, modifier le texte et publier, sans que tu quittes l'éditeur. C'est la même porte de relecture que dans l'application web : rien ne devient public tant que quelque chose ne l'approuve pas.
Connecter Claude Code
Crée d'abord une clé API (Paramètres, Clés API), puis ajoute le serveur avec ta clé dans l'en-tête :
claude mcp add --transport http changeloop \
https://api.changeloop.dev/mcp \
--header "Authorization: Bearer clapi_YOUR_KEY"
Pour un client qui lit à la place une configuration JSON, la même chose ressemble à ça :
{
"mcpServers": {
"changeloop": {
"type": "http",
"url": "https://api.changeloop.dev/mcp",
"headers": { "Authorization": "Bearer clapi_YOUR_KEY" }
}
}
}
Il n'y a pas encore de flux OAuth. L'authentification, c'est la clé API dans l'en-tête, ce qui est exactement ce que font les deux commandes ci-dessus. Révoquer cette clé dans Paramètres déconnecte l'agent à sa prochaine requête.
Ce que l'agent peut faire
Sept outils, et la liste est délibérément courte. Tout le reste que ce produit peut faire est accessible via l'API REST avec la même clé ; chaque outil exposé à un agent est une chose de plus qu'on peut le convaincre d'appeler.
- list_pending_entries, list_published_entries, get_entry - lisent tes entrées. Les entrées en attente ne sont pas publiques.
- update_entry - change le titre ou le corps markdown d'une entrée. Le HTML servi par le flux est régénéré depuis ton markdown par notre outil de nettoyage ; un agent ne peut pas fournir de HTML.
- approve_entry - publie. C'est public et immédiat, et ça notifie tout feedback lié sur GitHub. Seule une entrée en attente peut être approuvée.
- discard_entry - garde une entrée hors du changelog. Réversible depuis l'application web.
- get_changelog_info - ton id de flux et les adresses depuis lesquelles ton changelog est servi.
Ce qu'il ne peut pas faire
Chaque outil est limité à l'équipe à laquelle appartient la clé, et aucun n'accepte une équipe comme argument, il n'y a donc rien avec quoi viser une autre équipe même si quelqu'un essayait. Le serveur n'accepte pas de session navigateur, seulement une clé : une requête doit joindre la créance délibérément. Et une clé ne peut ni gérer des clés ni télécharger ton export de données, un agent connecté ainsi ne peut donc pas se créer une seconde créance ni extraire tes données en un seul appel.
Clés API
Tout ce qui précède est anonyme et ne nécessite aucune créance. L'API authentifiée, tes paramètres et ta boîte de relecture, est une surface différente, et elle accepte soit une session navigateur authentifiée, soit une clé API. Les clés sont pour les scripts et les agents : tout ce qui doit atteindre ton changelog sans une personne devant un clavier.
Authorization: Bearer clapi_YOUR_KEYCrée-en une dans l'application, dans Paramètres, sous l'onglet Clés API. La clé est affichée une fois, au moment où tu la crées, et jamais plus : nous ne conservons qu'un hash de celle-ci, il n'y a donc aucun écran qui puisse te la montrer une seconde fois. Si tu la perds, révoque-la et crées-en une autre.
Ce qu'une clé peut et ne peut pas faire
Une clé porte le même accès qu'une connexion, limité à la seule équipe où elle a été créée, avec deux exceptions délibérées. Elle ne peut pas gérer des clés API, et elle ne peut pas télécharger ton export de données. Les deux exigent une vraie connexion, pour qu'une clé fuitée ne puisse pas se créer un remplaçant, ne puisse pas révoquer les clés avec lesquelles tu la bloquerais, et ne puisse pas extraire les données de ton équipe en une seule requête.
Révocation
Une révocation prend effet à la requête suivante. Une clé révoquée répond 401 exactement comme une clé inconnue, et continue de répondre 401 même depuis un navigateur qui a encore une session valide, car une requête avec un en-tête Authorization n'est jamais silencieusement retentée comme une requête avec cookie. La clé révoquée reste listée avec la date de révocation et celle de dernière utilisation, ce qui est exactement ce dont tu as besoin pour déterminer ce qu'une clé fuitée a atteint.
Offres et limites
L'offre gratuite rédige 20 changements fusionnés par mois et plafonne à 50 par jour le nombre de fusions examinées, de retours triés, de cartes de roadmap rédigées et de versions alternatives ; l'offre équipe n'a pas de plafond strict. Paramètres, Offre et utilisation montre chaque budget tel que le produit le compte lui-même, avec le moment où il se réinitialise, avant qu'un refus ne survienne. Le travail qui arrive au-delà d'un plafond est retenu, pas perdu : une entrée hors quota attend dans la boîte de réception, et un brouillon de roadmap refusé peut être relancé une fois la fenêtre passée.
GitLab et Bitbucket
Un projet GitLab ou un dépôt Bitbucket peut alimenter ton changelog tout comme un dépôt GitHub : connecte-le dans Paramètres, puis GitLab, ou Paramètres, puis Bitbucket, ajoute le webhook que nous te donnons (ou, sur bitbucket.org, laisse Connect with Bitbucket l'ajouter si la page Bitbucket propose ce bouton), et chaque changement fusionné dans la branche que tu indiques devient une entrée en brouillon dans ta boîte de relecture, écrite de la même façon et soumise à la même relecture humaine. Les entrées proviennent des pull requests ou merge requests fusionnées ou, sur GitHub et Bitbucket, des pushs si tu choisis le mode push dans Paramètres, puis What creates drafts. Les projets GitLab ne produisent des brouillons qu'à partir des merge requests.
Connecter un projet
Les projets GitLab se connectent dans Paramètres, puis GitLab, et les dépôts Bitbucket dans Paramètres, puis Bitbucket. Saisis le chemin (sur GitLab, le groupe et le projet, comme acme/web ; sur Bitbucket, le workspace et le dépôt, comme acme/app) et nous te renvoyons une adresse webhook et un secret. Colle les deux dans les paramètres webhook de l'autre côté : sur GitLab, coche Merge request events, sur Bitbucket, coche les déclencheurs Merged pull request et Push repository. Les instances auto-hébergées fonctionnent, via https. Le secret est affiché une fois, à ce moment-là. Si tu le perds, supprime le projet et reconnecte-le. Sur bitbucket.org, si la page Bitbucket affiche un bouton Connect with Bitbucket, tu peux éviter le copier-coller : clique dessus, autorise l'accès une fois, et nous lisons la branche principale du dépôt et ajoutons le webhook pour toi. Il te faut les droits d'administration sur le dépôt. Avec Bitbucket auto-hébergé, ou si tu préfères coller, choisis Set it up by hand et tu obtiens l'adresse et le secret comme ci-dessus. Si tu supprimes un dépôt Bitbucket puis le reconnectes, supprime aussi son ancien webhook sur Bitbucket, dans Repository settings, puis Webhooks. Une fois un projet connecté, tu peux changer sa branche et activer la publication automatique sur sa ligne, et si une livraison a été ignorée, la ligne indique pourquoi.
Pourquoi Bitbucket demande une branche et pas GitLab
GitLab nous dit quelle branche ton projet traite comme celle par défaut, tu peux donc laisser le champ vide et cela signifie précisément ça. Bitbucket n'envoie aucune branche par défaut, donc si nous te laissions laisser le champ vide nous n'aurions rien avec quoi comparer, et ton webhook resterait avec l'air d'être parfaitement installé sans jamais produire une seule entrée. Nous préférons te poser une question plutôt que laisser ça arriver. Avec Connect with Bitbucket, nous demandons la branche principale à Bitbucket au moment où tu autorises l'accès, tu n'as donc pas à la saisir.
Ce qu'ils ne couvrent pas encore
Des entrées de changelog, et rien d'autre. Que le widget de feedback t'ouvre une issue, que la réponse soit publiée sur cette issue quand le correctif sort, que la roadmap publique soit alimentée par des étiquettes d'issues, et l'aperçu de la source dans la boîte de relecture, tout ça est aujourd'hui exclusif à GitHub.
Nous préférons dire la raison plutôt que la masquer. Chacune de ces choses nécessite un jeton d'accès avec droit d'écriture sur ton projet, conservé par nous. Les entrées de changelog n'en nécessitent aucun, car tout ce à partir de quoi elles sont écrites arrive dans le webhook lui-même, connecter GitLab ou Bitbucket par le webhook ne nous donne donc aucun identifiant ni aucun accès en lecture à ton code. Connect with Bitbucket est la seule exception. Bitbucket nous prête, pour une seule requête, un jeton qui peut lire le dépôt et ses pull requests et gérer ses webhooks, que nous utilisons uniquement pour lire la branche principale et ajouter le webhook, puis que nous jetons. Rien n'est conservé. Nous préférons livrer la partie qui ne te coûte rien plutôt que demander un jeton pour compléter une liste de fonctions.
Autres versions d'une entrée
Un changement doit souvent être expliqué plus d'une fois : aux clients dans le changelog, à quiconque répond à des questions à ce sujet, et dans un canal où personne ne lit quatre paragraphes. Depuis la boîte de relecture, tu peux rédiger deux versions supplémentaires d'une entrée avant de l'approuver.
Une version d'annonce fait une ou deux lignes, et c'est ce qui est publié sur Slack quand tu approuves l'entrée, à la place du texte complet. Une note de support est un briefing interne : ce qui a changé, ce que les clients remarqueront, et une phrase que quelqu'un du support pourrait dire quasi textuellement. Les deux sont des brouillons que tu peux réécrire avant de les utiliser, et les deux peuvent être supprimés.
Aucune des deux n'est publiée
Ces versions n'apparaissent jamais sur ta page de changelog, dans aucun flux, dans le widget ni dans l'API qui les sert. La note de support en particulier est écrite pour des personnes de ton entreprise et peut être plus directe que l'entrée elle-même. Elle n'existe que dans ta boîte de relecture et, si tu les utilises, dans ta propre copie.
À partir de quoi elles sont écrites
Toujours à partir de l'entrée, jamais de la pull request. C'est délibéré : l'entrée est déjà passée par la règle qui garde les correctifs de sécurité vagues, et par ta propre relecture. Une version réécrite à partir d'elle ne peut pas réintroduire un détail que tu as retiré, car ce détail ne fait pas partie de ce qu'a reçu le modèle.
Annoncer sur Slack
Approuve une entrée et elle peut être publiée sur un canal Slack au moment même où elle devient publique. Connecte ça dans Paramètres, sous l'onglet Slack : crée un webhook entrant dans ton propre workspace, choisis le canal et colle l'URL. Au-delà de ce webhook, rien n'est installé de ton côté, et nous ne demandons aucun accès à ton workspace.
Le message porte le titre de l'entrée, le texte tel que tu l'as approuvé, sa catégorie et ses étiquettes, et un lien de retour vers l'entrée sur ton changelog. Le markdown est traduit en ce que Slack rend réellement, une entrée n'arrive donc pas en affichant ses propres astérisques.
L'URL du webhook est une créance
Quiconque détient cette URL peut publier dans le canal, nous la traitons donc comme un mot de passe : elle est enregistrée, et après ça, aucun écran ni aucune réponse d'API ne la montre plus jamais, y compris ton propre export de données. Ce que tu vois ensuite est un masque, suffisant pour distinguer deux webhooks et inutile pour quiconque d'autre. Nous n'acceptons qu'une adresse hooks.slack.com, une URL mal saisie ou substituée est donc rejetée plutôt qu'interrogée.
Quand ça arrête de fonctionner
Si tu supprimes l'application dans Slack ou archives le canal, le webhook arrête de fonctionner définitivement. Nous le remarquons au premier message refusé, désactivons les annonces et l'indiquons dans l'onglet Slack avec la raison et la date. Que nous ne continuions pas à réessayer en silence est délibéré : un changelog que personne n'a annoncé ressemble exactement à un que personne n'a lu, et cette différence mérite d'être communiquée.
Mettre en pause
Mettre en pause arrête les annonces et conserve le webhook, reprendre est donc un clic au lieu d'un autre passage par Slack. Déconnecter supprime complètement l'URL. Dans les deux cas, la publication elle-même n'en est pas affectée : Slack est un canal sur lequel publie ton changelog, jamais une porte qu'il attend. Si Slack est injoignable quand tu approuves quelque chose, l'entrée est publiée quand même et l'annonce est retentée toute seule.
RSS et JSON Feed
GEThttps://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/rss.xmlGEThttps://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/feed.jsonLes mêmes entrées publiées comme flux abonnable, dans les deux formats que comprennent les lecteurs de flux : RSS 2.0 et JSON Feed 1.1. Les deux acceptent les mêmes filtres repos, category et tag que le flux du changelog et portent le même Cache-Control et ETag. Aucun des deux ne pagine : un lecteur interroge la tête du flux, ils ne renvoient donc que les entrées les plus récentes, sans curseur.
Le texte de l'entrée est le HTML nettoyé, enveloppé en CDATA pour RSS et comme content_html pour JSON Feed. JSON Feed porte en plus les couleurs de tes étiquettes sous une extension avec espace de noms _changelogapp ; pas RSS, car aucun lecteur ne les peindrait.
La page hébergée signale les deux comme liens rel="alternate", donc un navigateur ou un lecteur qui y atterrit peut s'abonner sans qu'on lui indique les chemins.
Une entrée seule
GEThttps://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/entries/ENTRY_IDRenvoie une seule entrée publiée, le même objet que porte le flux du changelog dans son tableau data. C'est vers ça que pointent les permaliens des flux, et c'est utile quand tu as un id et ne veux pas parcourir le flux pour la trouver. Un id inconnu, ou d'une entrée non publiée, renvoie 404 avec le même corps que tout autre id inconnu.
Le flux markdown
GEThttps://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/changelog.mdLes mêmes entrées publiées en markdown simple, servies comme text/markdown. Il existe pour les lecteurs qui ne sont pas des navigateurs : un LLM ou un agent qui répond à ce qui a changé récemment dans ce produit reçoit le texte sans analyser du RSS ni parcourir du JSON. Il accepte les mêmes filtres repos, category et tag que le flux du changelog, porte le même Cache-Control et ETag, et répond 304 à une requête conditionnelle exactement comme les deux autres.
Chaque entrée est une section : le titre en en-tête, puis une seule ligne avec date, catégorie et étiquettes éventuelles, puis le texte de l'entrée tel qu'il a été écrit, puis le lien Learn more si l'entrée en a un, puis son permalien. Le document commence par le titre et la description de ton flux et renvoie vers la page hébergée. Si rien n'est encore publié, ça le dit en une phrase au lieu de renvoyer un corps vide, pour qu'un lecteur puisse distinguer ça d'une requête échouée.
La page hébergée le signale comme lien rel="alternate" avec type text/markdown, à côté des liens RSS et JSON Feed, donc un agent qui a chargé le HTML peut le trouver sans qu'on lui indique le chemin.
Ce qui est servi, c'est le markdown que nous avons rédigé et que tu as approuvé, pas le HTML nettoyé. C'est sûr en tant que markdown, qui est inerte, et c'est pourquoi cette réponse n'est jamais text/html. Si tu le rends toi-même, échappe-le comme tu échapperais tout autre markdown non fiable : les entrées rédigées depuis un dépôt public peuvent être influencées par quiconque peut y ouvrir une pull request.
Recueillir du feedback sur ton propre site
Ajoute tes origines avant de tester ça
C'est le seul endpoint du produit qui écrit, il n'accepte donc pas les requêtes de n'importe où. Il compare l'en-tête Origin du navigateur à une liste blanche par équipe, et cette liste commence vide. Vide signifie tout rejeter, pas tout permettre. Tant que tu n'as pas ajouté l'origine sur laquelle tu intègres, chaque envoi revient avec 403 et {"error":"origin_not_allowed"}, et rien n'atteint ta boîte. Si ton formulaire semble correct et échoue quand même, c'est presque toujours la raison. Fixe la liste avec un PATCH authentifié sur /v1/settings/feed avec {"allowedOrigins": ["https://your-site.example"]}, et relis-la avec un GET sur le même chemin, qui répond avec ton publicId, tes allowedOrigins, et le feedTitle et feedDescription que tes abonnés voient dans un lecteur de flux. Nous enregistrons chaque origine exactement dans la forme où un navigateur l'envoie, une barre oblique finale ou un port par défaut explicite dans ce que tu envoies n'est donc pas un problème.
POST/v1/public/YOUR_PUBLIC_ID/feedbackPOST 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 doit ressembler à une adresse email et faire 254 caractères ou moins. message ne peut pas être vide et doit peser 2 Ko ou moins, mesurés en octets UTF-8, pas en caractères. Le corps JSON dans son ensemble est limité à 8 Ko. Il y a un champ de plus, website : c'est un honeypot, omets-le donc, ou envoie-le vide si tu le rends comme un champ caché, comme le fait notre widget.
Il vaut la peine de comprendre le honeypot avant de déboguer quoi que ce soit à son sujet. Si website arrive avec du contenu, nous répondons 202 avec un id d'envoi d'apparence parfaitement normale et ne faisons ensuite rien du tout, car un bot qui apprend qu'il a été repéré essaie simplement autrement. C'est la bonne réponse pour un bot et une réponse déroutante pour toi, donc si ton propre formulaire a un champ nommé website qu'un navigateur pourrait auto-remplir, renomme-le ou omets-le. Un envoi qui semble accepté et n'apparaît jamais, c'est presque toujours ça.
Un envoi que nous acceptons renvoie 202 avec un publicSubmissionId. Rends-le à l'expéditeur et conserve-le si tu peux : c'est la seule façon pour lui de vérifier ce qui s'est passé ensuite.
Les cas d'erreur sont 400 avec invalid_email ou invalid_message pour une forme incorrecte, 413 avec email_too_large ou message_too_large pour une forme correcte mais trop volumineuse, 429 avec rate_limited au-delà de 5 envois par minute ou 30 par heure depuis une adresse contre un flux, 403 avec origin_not_allowed, et 404 avec not_found pour un id de flux que nous ne reconnaissons pas.
Il y a aussi un plafond quotidien par équipe sur la quantité de travail en aval que les envois peuvent déclencher. Au-delà, nous continuons quand même à tout accepter et à tout enregistrer, ça attend simplement que quelqu'un de ton équipe le regarde plutôt que d'ouvrir quelque chose tout seul.
Vérifier un envoi
GET/v1/public/YOUR_PUBLIC_ID/feedback/PUBLIC_SUBMISSION_IDRépond avec status, plus githubIssueUrl dès qu'une issue existe pour cet envoi, plus shippedEntry avec un titre et un lien dès que le travail est sorti. L'adresse email de l'expéditeur n'est jamais lue depuis notre base de données pour cette route, encore moins renvoyée, et c'est ce qui rend la réponse sûre à rendre sur une page que n'importe qui peut voir. L'id est toute la créance, traite-le comme tel. Il est limité à 20 requêtes par minute et 200 par heure par adresse et par flux.
Cache, CORS et requêtes conditionnelles
Les deux flux envoient Cache-Control: public, max-age=60, stale-while-revalidate=300 avec un ETag fort. Renvoie cet ETag comme If-None-Match et un flux inchangé répond 304 sans corps. Aucun champ de la réponse ne porte une valeur d'horloge, l'ETag reste donc stable quand nous re-rendons des données inchangées, et c'est ce qui rend fiables ces 304.
Les deux flux et la vérification de statut sont des lectures anonymes et répondent avec Access-Control-Allow-Origin: *, tu peux donc les appeler depuis n'importe quelle origine, avec curl ou depuis une étape de build. Le POST de feedback est l'exception : il répond avec ton origine autorisée et un Vary: Origin, jamais avec un joker. Les navigateurs lui font un preflight, et un preflight répond toujours 204 que l'origine soit autorisée ou non, ça ne peut donc pas servir à sonder tes paramètres.