Changelog API
The public changelog feed — GET /api/v1/meta/changelog. The JSON shape, what is never in it, how it is ordered, the signed webhook that removes the need to poll, and how to mirror another panel's feed.
Every SpipCP panel serves its release notes as one JSON document, unauthenticated, over CORS. That document is the contract: the docs changelog page reads it, the panel's own "what's new" reads it, and any consumer built against it reads the same bytes. Nothing else — not the database, not the panel's internals — is a supported thing to depend on.
This page is enough to build a consumer. Reading panel source should not be necessary.
The feed
GET https://panel.example.com/api/v1/meta/changelogNo authentication, no token, no session. The response carries
access-control-allow-origin: *, so a browser on any origin may fetch it. It is safe to cache — a
release is published rarely and the document is small.
Companion endpoint, same lane: GET /api/v1/meta/version → { "version": "v1.4.0" } — the running
image tag (see below). Both are GET-only; a POST to either is not routed and returns 404.
{
// Which build artifact this box is running: the value of SPIPCP_TAG. NOT the product version —
// it can be "latest", "local", or anything the operator pinned. See /docs/platform/versioning.
"imageTag": "v1.4.0",
// What this panel claims to BE, as a semver — the release whose imageTag matches the running tag;
// failing that, the newest published release; and `null` on a panel that has published nothing.
// This is the value to display as "the running version". Never parse `imageTag` for it.
"currentVersion": "1.4.0",
// Newest first, BY PUBLICATION DATE. Already ordered — do not re-sort (see "Ordering" below).
"releases": [
{
// The identity of the release, and the anchor the docs page links to. Always a valid semver.
"version": "1.4.0",
// The human name for it. Always present, may be short.
"title": "The changelog",
// Publication date, date-only, UTC. Releases are day-grained; there is no time component.
// A release without a date does not exist — see "What is never in it".
"date": "2026-07-11",
// "major" | "minor" | "patch". A major means: read the notes before updating.
"severity": "minor",
// One optional paragraph of plain text, or null. Never markdown, never HTML (see below).
"summary": "Releases are authored in the panel and published as one feed.",
// The release notes proper. Plain-text lines, each tagged with exactly one category.
// Categories: "new" | "improved" | "fixed". The array may be empty; a category may be absent
// entirely (render nothing for it — do not render an empty "Fixed" heading).
"items": [
{ "category": "new", "text": "Write and publish releases from Admin → Changelog." },
{ "category": "improved", "text": "The public changelog is a real GET endpoint." },
{ "category": "fixed", "text": "The version badge in the docs renders again." }
]
}
]
}Items are plain text, and that is a guarantee, not an omission. The same string is rendered by the docs page, by the release email, and by any consumer. The moment items were markdown, those three would need a renderer and would not agree. Escape it for the target medium and render it as text.
Unknown fields may appear in a future release. Ignoring unrecognized fields rather than rejecting the document is what keeps an older consumer working against a newer panel.
What is never in it
These are properties safe to rely on, not habits that happen to hold:
- Drafts. A release that has not been published has no
dateand is not in the feed. It does not appear, partially appear, or appear with a flag. This is enforced in the one function that maps a stored release onto the wire, and asserted in the security test suite — because an unannounced release leaking is the one mistake here with real consequences. - Internal phase IDs. A release may carry an internal spec ID used for internal bookkeeping. It is never serialised. Phase numbers are not product versions (why), and a consumer that never sees one cannot mistake one for a version.
- Authorship. Who wrote or published a release is not in the feed — no user ID, no name, no email.
- Row internals. No database ID, no
source, no notification flags, no timestamps beyonddate.
The mapper builds a fresh object field by field rather than deleting fields from a row, so a column added to the panel tomorrow cannot leak into the feed by being forgotten.
Translations (optional, additive)
A release may carry an i18n map — the same release, in the languages it has been translated into:
{
"version": "1.6.0",
"title": "Thirteen languages",
"summary": "The interface is translated.",
"items": [{ "category": "new", "text": "Thirteen languages." }],
"i18n": {
"fr": {
"title": "Treize langues",
"summary": "L'interface est traduite.",
"items": [{ "text": "Treize langues." }],
"machine": true
}
}
}A consumer may ignore it entirely and stay correct. The field is absent unless a release has been translated, and a panel with no translation engine configured emits exactly the feed it emitted before. Nothing about the existing fields changed.
The rule for a consumer that does use it:
- Use the reader's language if it is present; otherwise use the release's own
title,summaryanditems. Absent is not an error — it means the release has not been translated into that language, and the authored text is the right thing to show. itemsin a translation are aligned by index with the release'sitems, and carry onlytext. The category (new/improved/fixed) lives on the release's item and is not duplicated.machine: truemeans a machine wrote it and no human has reviewed it. Show that to the reader. It is in the feed precisely so that a consumer can be honest about it; the panel's own surfaces are.
The authored text remains authoritative in every case. A translation is derived from it, never the other way round.
currentVersion vs imageTag
The one genuinely confusing pair, so plainly:
| Field | Answers | Example |
|---|---|---|
imageTag | Which artifact is this box running? | v1.4.0, latest, local |
currentVersion | What does that software claim to be? | 1.4.0, or null |
They usually agree, because a release is tagged with its own version. When they disagree — an operator
pinned latest, or built from source — currentVersion is the answer to "what version is this" and
the tag is just a filename. A release announced before its image is cut has no matching tag at all, in
which case currentVersion falls back to the newest published release. Full explanation:
Versioning.
Ordering
releases arrives newest first, ordered by publication date. Take it as given.
Do not sort by version string
"0.10.0" < "0.9.0" under string comparison, so a lexical sort files a newer release underneath an
older one — and it does it silently, on exactly the release where it matters. Semver here is an
identity and a display string; the timeline is the date. Re-sorting is discouraged, but if
necessary, sort by date.
The webhook, for avoiding polling
Polling the feed is fully correct — the docs changelog page does nothing else, and a consumer that only
ever polls is never wrong. But a panel can also push on publish: add a target under the panel's
changelog.webhooks setting (a URL, a shared secret, an enabled flag) and every publish POSTs to it.
The body carries the release in feed shape — the identical object the GET returns, so one parser
serves both:
{ "event": "changelog.published", "release": { "version": "1.4.0", "title": "…", "date": "2026-07-11", "severity": "minor", "summary": "…", "items": [] } }The request is signed with the same HMAC scheme as SpipCP's probe webhooks — headers
x-spipcp-signature and x-spipcp-timestamp — so a receiver that already verifies SpipCP monitoring
alerts does not have to learn a second scheme: HMAC-SHA256 over `${timestamp}.${body}` keyed by
the target's secret, hex-encoded, prefixed sha256=. The timestamp is signed into the digest, so a
captured body cannot be replayed under a fresh time.
import { createHmac, timingSafeEqual } from "node:crypto";
// `raw` is the RAW request body — verify before JSON.parse, not after.
function verify(raw, headers, secret) {
const timestamp = headers["x-spipcp-timestamp"];
const signature = headers["x-spipcp-signature"];
if (!timestamp || !signature) return false;
// Reject anything older than five minutes: the signature is valid forever, the delivery is not.
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;
const expected = `sha256=${createHmac("sha256", secret).update(`${timestamp}.${raw}`).digest("hex")}`;
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b); // constant-time, never `===`
}Delivery failures are retried and never fail the publish, so treat the webhook as an accelerant, not a source of truth: reconcile against the feed. A publish that no listener acknowledged is still published.
Mirroring another panel's feed
A panel with the changelog.upstream setting pointed at another panel's feed URL stops being an author
and becomes a mirror: it pulls that feed, stores the releases, and renders them through the same
surfaces — the changelog page, the "what's new" panel, and its own public feed, which now serves the
upstream's releases. This is how an operator whose panel has published nothing shows SpipCP's real
changelog instead of an empty page, and it is how the authority for the changelog can move somewhere else
later without a single consumer changing a line. Unset, a panel is its own authority and makes no outbound
request — a self-hosted panel never phones home unless explicitly configured to.