Developer docs

Last updated 19 August 2026.

Everything Changeloop publishes for you is plain JSON over HTTPS. There is no SDK to install, no API key to rotate and no sign-in step: the two feeds below are anonymous public reads keyed by your feed id. Swap YOUR_PUBLIC_ID for yours in any sample on this page.

One thing to know before you start: your public feed id lives in the app itself. Sign in, open Settings, and it is right there on the Feed & widget tab, the one you land on by default, along with ready-made changelog.json and roadmap.json links, a link to your hosted feed page, and the widget snippet below, each with its own copy button.

Your changelog in about ten lines of React

Paste this into a component and you have a working changelog. There is nothing else to add.

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 is the markdown we drafted, as text. If you would rather render formatted output, use htmlContent instead: it is built server-side by our own sanitiser from a fixed allowlist of tags and attributes, and it is the only value in any of these responses that is meant to be injected as markup. Everything else is text, and entries drafted from a public repository can be influenced by anyone who can open a pull request there, so treat them accordingly.

The changelog feed

GET /v1/public/YOUR_PUBLIC_ID/changelog.json

Your published entries, newest first, with the newest id breaking ties on identical timestamps.

Query parameters

  • repos takes a comma separated list of full repository names, for example acme/web,acme/api. Only entries from those repositories come back. Leave it off and you get all of them.
  • limit is how many entries you want per page. It defaults to 20, anything above 50 is clamped to 50, and anything we cannot parse as a positive number falls back to 20 rather than failing.
  • cursor is opaque. Take the nextCursor value from the previous response and hand it straight back. A cursor we cannot decode is treated as no cursor, so you get the first page again instead of an error.

Response

{
  "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" }
}

Every entry carries the same nine keys: id, title, mdContent, htmlContent, repoFullName, category, tags, learnMoreUrl and publishedAt. category is one of feature, fix or internal, and is null when the drafter did not set one, publishedAt is an ISO 8601 string, and htmlContent is an empty string on an entry that never went through the drafter. tags is an array of your own product area names and is empty when none were assigned, learnMoreUrl is null unless a reviewer added one, and the colour to draw each tag in comes from the tagColors map on the response rather than from the entry, so a tag you have since removed from your vocabulary simply renders without a colour. nextCursor is null when you have reached the end.

An unknown feed id answers 404 with {"error":"not_found"}, and so does a malformed one. The two are deliberately indistinguishable, so this endpoint cannot be used to work out which ids exist.

The roadmap feed

GET /v1/public/YOUR_PUBLIC_ID/roadmap.json

The same three columns your team keeps by hand.

{
  "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 is an array, not an object keyed by column name, and its order is part of the contract: planned, then building, then shipped. All three are always there, empty ones included, so you never have to tell "no such column" apart from "nothing in it yet". Render them in the order you received them and you match every other surface we build.

An item has exactly five keys: id, column, publicTitle, publicDescription and publishedAt. publicDescription is always a string and can be empty, never null. Nothing about the issue an item came from is exposed here, not the repository and not the issue number, and that is deliberate rather than an oversight we might fill in later.

This endpoint takes no query parameters at all. There is no cursor, no limit and no repository filter, because a roadmap is a small board a person curates rather than a log that grows forever. Each column returns up to 50 items and sets hasMore if it had more than that. hasMore is informational: there is no cursor to follow it with, so do not build a pager around it.

publicTitle and publicDescription are plain text drafted from issue titles and bodies, which on a public repository anyone can influence by opening an issue. They carry no HTML sanitising guarantee and are not the htmlContent exception. Render them as text.

The embeddable widget

If you would rather not build anything, drop in these two lines. The widget is a custom element that renders into a shadow root, so it neither inherits your styles nor leaks into them.

<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>

Both attributes are required. data-public-id is your feed id, data-api is the origin the widget fetches from. If either one is missing the element writes an error to the console and renders nothing at all, which is the first thing to check if you see an empty space where it should be.

It renders three tabs in this order: Updates, Roadmap and Feedback. The first two read the feeds above. The third posts to the endpoint below and keeps each submission id in localStorage, so a visitor can come back and see what happened to what they sent.

The script is served versioned. /widget.js always serves the newest build and is cached for an hour, so a release reaches your visitors without you touching anything. /widget-vN.js pins one build: once a version number has been served its bytes never change again, and it is cached for a year. Pin it if you would rather adopt changes on purpose.

Load exactly one widget script per page

The two URLs are alternatives, not layers. Both register the same custom element name, and a browser lets a name be registered only once per document: whichever script executes first wins, for the life of the page, and the second one is inert. So a page carrying both /widget.js and /widget-v5.js renders whichever the browser happened to run first, which is not something you control, and adding /widget-v5.js next to an existing /widget.js to pin the version does nothing at all. It is the older build that usually wins, because it is the one already in cache.

When it happens the widget writes a warning to the console naming both builds, so you are not left guessing. It cannot do more than warn: by the time the second copy runs, the first has already claimed the name. The fix is always to replace the script tag rather than add another one, and the same applies if a tag manager or a partial injects one for you. To move from the rolling build to a pinned one, change the src.

The hosted feed page

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

We host a plain page at that address too: your changelog and your roadmap board, rendered from the same two feeds above. It needs no sign-in and nothing set up on your side. It is also where we send people back once a loop closes: the Shipped comment we leave on a GitHub issue links here, and so does shippedEntry.link from the submission lookup above, both landing on the entry that shipped with its own #entry-ID anchor, which still finds the entry even if it has since moved to a later page.

Treat it as a fallback, not the integration. The changelog feed and the widget are still the way to put this into your own site so it looks like your product rather than ours; this page is for when you have not done that yet, and for loop-close links, which point here regardless of what else you have built.

The MCP server

POST https://api.changeloop.dev/mcp

If you work in Claude Code, ChatGPT or another agent that speaks the Model Context Protocol, you can connect it to your changelog directly. The agent can then see what is waiting for review, edit the wording, and publish, without you leaving the editor. It is the same review gate as the web app: nothing goes public until something approves it.

Connecting Claude Code

Create an API key first (Settings, API keys), then add the server with your key in the header:

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

For a client that reads a JSON config instead, the same thing looks like this:

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

There is no OAuth flow yet. Authentication is the API key in the header, which is what the two commands above are doing. Revoking that key in Settings disconnects the agent on its next request.

What the agent can do

Seven tools, and the list is deliberately short. Anything else this product can do is reachable over the REST API with the same key; every tool exposed to an agent is one more thing it can be talked into calling.

  • list_pending_entries, list_published_entries, get_entry - read your entries. Pending ones are not public.
  • update_entry - change the title or the markdown body of an entry. The HTML the feed serves is re-rendered from your markdown by our sanitiser; an agent cannot supply HTML.
  • approve_entry - publish. This is public and immediate, and it notifies any linked feedback on GitHub. Only a pending entry can be approved.
  • discard_entry - keep an entry out of the changelog. Reversible from the web app.
  • get_changelog_info - your feed id and the addresses your changelog is served from.

What it cannot do

Every tool is scoped to the team the key belongs to, and none of them takes a team as an argument, so there is nothing to point at another team even if something tried. The server does not accept a browser session, only a key: a request has to attach the credential deliberately. And a key cannot manage keys or download your data export, so an agent connected this way cannot mint itself a second credential or pull your data out in one call.

API keys

Everything above is anonymous and needs no credential. The authenticated API -- your settings, your review inbox -- is a different surface, and it accepts either a signed-in browser session or an API key. Keys are for scripts and agents: anything that has to reach your changelog without a person at a keyboard.

Authorization: Bearer clapi_YOUR_KEY

Create one in the app under Settings, on the API keys tab. The key is shown once, at the moment you create it, and never again: we store only a hash of it, so there is no screen anywhere that can show it to you a second time. If you lose it, revoke it and make another.

What a key can and cannot do

A key carries the same access as signing in, scoped to the one team it was created in, with two deliberate exceptions. It cannot manage API keys, and it cannot download your data export. Both of those need a real sign-in, so that a key which leaks cannot mint replacements for itself, cannot revoke the keys you would use to lock it out, and cannot pull your team data out in a single request.

Revoking

Revoking takes effect on the next request. A revoked key answers 401 exactly like an unknown one, and it keeps answering 401 even from a browser that still holds a valid session, because a request carrying an Authorization header is never quietly retried as a cookie request. The revoked key stays listed with the date it was revoked and the date it was last used, which is what you want when you are working out what a leaked key reached.

GitLab and Bitbucket

A GitLab project or a Bitbucket repository can feed your changelog the same way a GitHub repository does: connect it under Settings, add the webhook we give you, and every change merged into the branch you name becomes a draft entry in your review inbox, written the same way and gated by the same human review.

Connecting a project

Settings, then GitLab and Bitbucket. Pick the provider, enter the project path (the group and project, like acme/web) and we hand back a webhook address and a secret. Paste both into the webhook settings on their side: on GitLab tick Merge request events, on Bitbucket choose the Merged pull request trigger. Self-managed instances work, over https. The secret is shown once, at that moment. If you lose it, remove the project and connect it again.

Why Bitbucket asks for a branch and GitLab does not

GitLab tells us which branch your project treats as its default, so you can leave the field blank and mean that. Bitbucket sends no default branch at all, so if we let you leave it blank we would have nothing to compare against and your webhook would sit there looking perfectly installed while never producing a single entry. We would rather ask you one question than let that happen.

What they do not cover yet

Changelog entries, and nothing else. The feedback widget filing an issue for you, the reply posted back on that issue when the fix ships, the public roadmap driven by issue labels, and the source preview in the review inbox are all GitHub-only today.

The reason is one we would rather state than paper over. Each of those needs an access token with write access to your project, kept by us. Changelog entries need none, because everything they are written from arrives in the webhook itself, so connecting GitLab or Bitbucket hands us no credential and gives us no read of your code. We would rather ship the part that costs you nothing than ask for a token to round out a feature list.

Other versions of an entry

One change usually has to be explained more than once: to customers in the changelog, to whoever answers questions about it, and in a channel where nobody reads four paragraphs. From the review inbox you can draft either of two extra versions of an entry before you approve it.

An announcement version is one or two lines, and it is what gets posted to Slack when you approve the entry, in place of the full text. A support note is an internal briefing: what changed, what customers will notice, and a sentence an agent could say almost verbatim. Both are drafts you can rewrite before they are used, and either can be removed.

Neither one is published

These versions never appear on your changelog page, in any feed, in the widget or over the API that serves them. The support note in particular is written for people inside your company and may be more direct than the entry itself. The only places it exists are your review inbox and, if you use them, your own copy of it.

What they are written from

Always the entry, never the pull request. That is deliberate: the entry has already been through the rule that keeps security fixes vague, and through your own review. A version rewritten from it cannot reintroduce a detail you removed, because the detail is not in what the model was given.

Announcing in Slack

Approve an entry and it can be posted to a Slack channel at the same moment it goes public. Connect it under Settings, on the Slack tab: create an incoming webhook in your own workspace, pick the channel, and paste the URL. Nothing is installed on your side beyond that webhook, and we ask for no access to your workspace.

The message carries the entry title, the text as you approved it, its category and tags, and a link back to the entry on your changelog. Markdown is translated into what Slack actually renders, so an entry does not arrive showing its own asterisks.

The webhook URL is a credential

Anyone holding that URL can post into the channel, so we treat it like a password: it is stored, and after that no screen and no API response ever shows it again, including your own data export. What you see afterwards is a mask, which is enough to tell two webhooks apart and useless to anyone else. We only ever accept a hooks.slack.com address, so a mistyped or substituted URL is refused rather than fetched.

When it stops working

If you remove the app in Slack or archive the channel, the webhook stops working permanently. We notice that on the first refused message, turn announcements off, and say so on the Slack tab with the reason and the date. It is deliberate that we do not keep retrying quietly: a changelog nobody announced looks exactly like one nobody read, and that is a difference worth being told about.

Pausing

Pause stops the announcements and keeps the webhook, so resuming is one press rather than another trip through Slack. Disconnect removes the URL entirely. Either way, publishing itself is unaffected: Slack is a channel your changelog posts to, never a gate it waits on. If Slack is unreachable when you approve something, the entry still publishes and the announcement is retried on its own.

RSS and JSON Feed

GET https://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/rss.xmlGET https://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/feed.json

The same published entries as a subscribable feed, in the two formats readers understand: RSS 2.0 and JSON Feed 1.1. Both take the same repos, category and tag filters as the changelog feed and carry the same Cache-Control and ETag. Neither pages: a reader polls the head of the feed, so these return the most recent entries only, with no cursor.

Entry text is the sanitised HTML, wrapped in CDATA for RSS and as content_html for JSON Feed. JSON Feed additionally carries your tag colours under a namespaced _changelogapp extension; RSS does not, because no reader would paint them.

The hosted page advertises both as rel="alternate" links, so a browser or reader that lands on it can subscribe without being told the paths.

One entry on its own

GET https://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/entries/ENTRY_ID

Returns a single published entry, the same object the changelog feed carries in its data array. It is what the permalinks in the feeds point at, and it is useful when you have an id and do not want to page through the feed to find it. An id that is unknown, or belongs to an entry that is not published, returns 404 with the same body as any other unknown id.

The markdown feed

GET https://api.changeloop.dev/v1/public/YOUR_PUBLIC_ID/changelog.md

The same published entries as plain markdown, served as text/markdown. It exists for readers that are not browsers: an LLM or an agent answering "what changed recently in this product" gets the text without parsing RSS or walking JSON. It takes the same repos, category and tag filters as the changelog feed, carries the same Cache-Control and ETag, and answers 304 to a conditional request exactly like the other two.

Each entry is a section: the title as a heading, then a single line carrying the date, the category and any tags, then the entry text as it was written, then the Learn more link if the entry has one, then its permalink. The document opens with your feed title and description and links back to the hosted page. When nothing is published yet it says so in a sentence rather than returning an empty body, so a reader can tell that apart from a failed fetch.

The hosted page advertises it as a rel="alternate" link with type text/markdown, alongside the RSS and JSON Feed links, so an agent that fetched the HTML can find it without being told the path.

What it serves is the markdown we drafted and you approved, not the sanitised HTML. That is safe as markdown, which is inert, and it is the reason this response is never text/html. If you render it yourself, escape it the way you would escape any other untrusted markdown: entries drafted from a public repository can be influenced by anyone who can open a pull request there.

Collecting feedback from your own site

Add your origins before you test this

This is the only endpoint in the product that writes, so it does not accept requests from just anywhere. It matches the browser Origin header against a per team allowlist, and that list starts out empty. Empty means reject everything, not allow everything. Until you add the origin you are embedding on, every single submission comes back 403 with {"error":"origin_not_allowed"} and nothing reaches your inbox. If your form looks correct and still fails, this is almost always the reason. Set the list with a signed-in PATCH to /v1/settings/feed carrying {"allowedOrigins": ["https://your-site.example"]}, and read it back with a GET to the same path, which answers with your publicId, your allowedOrigins, and the feedTitle and feedDescription your subscribers see in a feed reader. We store each origin in the exact form a browser sends it, so a trailing slash or an explicit default port in what you send is fine.

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 has to look like an email address and be 254 characters or fewer. message has to be non-empty and 2KB or less, measured in UTF-8 bytes rather than characters. The JSON body as a whole is capped at 8KB. There is one more field, website: it is a honeypot, so leave it out, or send it empty if you render it as a hidden input the way our widget does.

The honeypot is worth understanding before you debug anything with it. If website arrives with anything in it we answer 202 with a perfectly ordinary looking submission id and then do nothing at all, because a bot that learns it was caught just tries again differently. That is the right answer for a bot and a confusing one for you, so if your own form has a field named website that a browser might autofill, rename it or drop it. A submission that seems accepted and never appears is almost always this.

A submission we accept returns 202 with a publicSubmissionId. Hand that back to the person who sent it and keep it if you can: it is the only way they can look up what happened next.

The failure modes are 400 with invalid_email or invalid_message for the wrong shape, 413 with email_too_large or message_too_large for the right shape but too much of it, 429 with rate_limited past 5 submissions a minute or 30 an hour from one address against one feed, 403 with origin_not_allowed, and 404 with not_found for a feed id we do not recognise.

There is also a daily cap per team on how much downstream work submissions can set off. Past it we still accept and store everything that comes in, it simply waits for someone on your team to look at it rather than opening anything by itself.

Checking one submission

GET /v1/public/YOUR_PUBLIC_ID/feedback/PUBLIC_SUBMISSION_ID

Answers with status, plus githubIssueUrl once an issue exists for that submission, plus shippedEntry carrying a title and a link once the work is out. The submitter email address is never read from our database for this route, let alone returned, which is what makes the response safe to render on a page anyone can see. The id is the whole credential, so treat it like one. It is rate limited to 20 requests a minute and 200 an hour per address and feed.

Caching, CORS and conditional requests

Both feeds send Cache-Control: public, max-age=60, stale-while-revalidate=300 along with a strong ETag. Send that ETag back as If-None-Match and an unchanged feed answers 304 with no body. No response field carries a wall clock value, so the ETag stays stable when we re-render data that has not changed, which is what makes those 304s worth relying on.

The two feeds and the submission lookup are anonymous reads and answer with Access-Control-Allow-Origin: *, so you can call them from any origin, from curl, or from a build step. The feedback POST is the exception: it answers with your own allowed origin and a Vary: Origin, never with a wildcard. Browsers preflight it, and a preflight always answers 204 whether or not the origin is allowed, so it cannot be used to probe your settings.