StackPractices
beginner By Mathias Paulenko

API Changelog Template

A template for documenting API changes including breaking changes, new features, deprecations, and bug fixes.

Overview

Consumers want three answers: what changed, when, and do they need to act. An unstructured changelog — or none — means they diff your API or find breaking changes in production. This template gives you a standard format: every entry versioned, dated, severity-marked, with a migration note. Companion docs: OpenAPI documentation for the spec itself, API logging for the operational side, and deprecation notices for the long tail.

When to Use

Use this resource when:

  • Releasing a new API version or capability
  • Deprecating or removing an endpoint or field
  • Fixing a bug that affects API behavior
  • Publishing a monthly or quarterly API update

Solution

# API Changelog

## 2.5.0 — 2026-06-26

### Breaking Changes
- **Removed:** `GET /v1/orders/{id}/items` — Use `GET /v2/orders/{id}?expand=items` instead
- **Changed:** `total` field renamed to `totalAmount` in all order responses
- **Required:** `X-Request-ID` header now mandatory for all write operations

### New Capabilities
- **Added:** `POST /v2/orders/bulk` — Create up to 100 orders in a single request
- **Added:** `paymentStatus` field to order responses (`pending`, `paid`, `failed`)
- **Added:** Webhook event `order.payment_failed` for failed payment notifications

### Fixes
- **Fixed:** `GET /v2/products` now returns empty array `[]` instead of `null` when no products exist
- **Fixed:** Date fields now consistently return ISO 8601 format with timezone offset

### Deprecations
- **Deprecated:** `customer_id` query parameter — Use `customerId` instead (sunset: 2026-10-01)
- **Deprecated:** `v1` API base path — Migrate to `v2` before 2026-12-01

## 2.4.0 — 2026-05-15

### New Capabilities
- **Added:** `PATCH /v2/orders/{id}` for partial order updates
- **Added:** Rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`) to all responses

### Fixes
- **Fixed:** `500 Internal Server Error` on `POST /v2/orders` when `items` array was empty

---

## Versioning Policy

We follow [Semantic Versioning](https://semver.org/):
- **MAJOR:** Breaking changes that require consumer action
- **MINOR:** New capabilities, backward compatible
- **PATCH:** Bug fixes, backward compatible

## Change Categories

| Category | Description | Consumer Action Required |
|----------|-------------|--------------------------|
| Breaking Change | Existing integrations may fail | Yes — migration required |
| New Capability | New endpoints, fields, or behaviors | Optional — adopt when ready |
| Fix | Corrected bug or inconsistent behavior | No — but verify if you relied on old behavior |
| Deprecation | Capability scheduled for removal | Yes — before sunset date |

## Subscribe to Changes

- **RSS Feed:** https://developer.example.com/changelog.xml
- **Email:** Subscribe at https://developer.example.com/subscribe
- **Webhook:** Configure `api.changelog_published` event in your dashboard
- **Slack:** Join #api-announcements on our community Slack

Extended Template Example

Here is a more detailed changelog with several versions, showing how entries accumulate over time:

# API Changelog

## 3.0.0 — 2026-09-01

### Breaking Changes
- **Removed:** All v1 endpoints. Migrate to v2 or v3. See migration guide.
- **Changed:** Authentication switched from API keys to OAuth 2.0 bearer tokens
- **Changed:** Error response format standardized to RFC 7807 Problem Details
- **Removed:** `GET /v2/users/{id}/permissions` — Use `GET /v2/users/{id}?expand=permissions`
- **Changed:** Pagination `offset`/`limit` replaced with `cursor`/`limit`

### New Capabilities
- **Added:** `POST /v3/webhooks` — Programmatically register webhook endpoints
- **Added:** Bulk export endpoint `GET /v3/orders/export?format=csv`
- **Added:** Idempotency key support on all POST endpoints via `Idempotency-Key` header

### Fixes
- **Fixed:** Rate limit counters now reset correctly at midnight UTC instead of local server time
- **Fixed:** `PATCH /v2/orders/{id}` no longer overwrites `null` values when field is omitted

### Deprecations
- **Deprecated:** `v2` API base path — Migrate to `v3` before 2027-03-01
- **Deprecated:** `X-API-Key` header — Use `Authorization: Bearer <token>` instead

## 2.5.0 — 2026-06-26

### Breaking Changes
- **Removed:** `GET /v1/orders/{id}/items` — Use `GET /v2/orders/{id}?expand=items` instead
- **Changed:** `total` field renamed to `totalAmount` in all order responses
- **Required:** `X-Request-ID` header now mandatory for all write operations

### New Capabilities
- **Added:** `POST /v2/orders/bulk` — Create up to 100 orders in a single request
- **Added:** `paymentStatus` field to order responses (`pending`, `paid`, `failed`)
- **Added:** Webhook event `order.payment_failed` for failed payment notifications

### Fixes
- **Fixed:** `GET /v2/products` now returns empty array `[]` instead of `null` when no products exist
- **Fixed:** Date fields now consistently return ISO 8601 format with timezone offset

### Deprecations
- **Deprecated:** `customer_id` query parameter — Use `customerId` instead (sunset: 2026-10-01)
- **Deprecated:** `v1` API base path — Migrate to `v2` before 2026-12-01

## 2.4.0 — 2026-05-15

### New Capabilities
- **Added:** `PATCH /v2/orders/{id}` for partial order updates
- **Added:** Rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`) to all responses

### Fixes
- **Fixed:** `500 Internal Server Error` on `POST /v2/orders` when `items` array was empty

Operating the Changelog

A changelog that gets written whenever someone remembers is a changelog that stops getting written. Treat it as a release artifact with an owner and a step in the pipeline, not as a document someone maintains.

Who writes the entry. The engineer shipping the change drafts it — they know the consumer impact better than a docs person reconstructing it from commits later. A reviewer (API lead or developer advocate) edits for the consumer-facing questions: does it say what to do, does it name the sunset date, would a consumer who skipped two versions understand it? That review is minutes, not days, if the entry arrives with the PR.

When it publishes. Before or with the deploy, never after. For breaking changes and deprecations the entry goes out ahead of the change — that is the notification, not a report of one. A deprecation posted the same day the header starts flying gives consumers zero warning; the notice period is the point.

Where it lives. One file rendered on the docs site is the baseline. RSS and email reach the consumers who will never reload the page, and the api.changelog_published webhook exists for teams that want to automate their own triage. Internal release notes stay internal — mixing CI work into the consumer page teaches readers to stop reading.

What gets recorded per entry. Version, date, category, and the action the consumer must take — “use GET /v2/orders/{id}?expand=items” — not just what changed. If an entry doesn’t tell the reader whether they need to act, it has failed its one job.

Breaking vs Non-Breaking Decisions

The line that matters to consumers is whether existing client code keeps working unchanged. Everything else is detail.

ChangeBreaking?Why
Remove an endpoint or fieldYesClients that call/read it break
Rename a field or parameterYesRenaming is removing + adding
Change a field’s type or formatYestotal: 42 → total: "42.00" breaks parsers
Make an optional field requiredYesExisting requests start failing validation
Change pagination mechanismYesoffset→cursor is a contract change
Add an endpoint, field, or enum valueUsually noUnless clients validate responses strictly — say so in the entry
Add a required headerYesOld requests now reject
Tighten a rate limitGray zoneDocument it prominently even though it’s a policy change, not schema
Fix a bug consumers rely onGray zoneIf the buggy behavior was documented, it’s breaking; if not, fix it and note the inconsistency
Deprecate without removingNoIt’s a promise about the future — that’s exactly what the category is for

When genuinely unsure, treat it as breaking. The cost of over-announcing is a louder changelog; the cost of under-announcing is broken consumers who stop trusting the changelog entirely — and once that trust goes, the page is decoration.

Changelog Automation

Generating changelogs manually is error-prone. Use commit message conventions and tooling to produce changelogs automatically or semi-automatically.

Conventional Commits

Structure commit messages so a tool can parse them:

feat(api): add bulk order creation endpoint

POST /v2/orders/bulk accepts up to 100 orders in one request.
Returns 207 Multi-Status with per-order results.

BREAKING CHANGE: total field renamed to totalAmount in order responses

Node.js Script to Generate Changelog from Git Tags

const { execSync } = "child_process";

function generateChangelog(fromTag, toTag) {
  const log = execSync(
    `git log ${fromTag}..${toTag} --pretty=format:"%s|%H" --no-merges`
  ).toString();

  const entries = log.split("\n").map((line) => {
    const [message, hash] = line.split("|");
    return { message: message.trim(), hash };
  });

  const breaking = entries.filter((e) =>
    e.message.includes("BREAKING CHANGE") || e.message.startsWith("feat!")
  );
  const features = entries.filter((e) =>
    e.message.startsWith("feat") && !e.message.includes("BREAKING")
  );
  const fixes = entries.filter((e) => e.message.startsWith("fix"));
  const deprecations = entries.filter((e) =>
    e.message.startsWith("deprecate") || e.message.includes("DEPRECATED")
  );

  let output = `## ${toTag} — ${new Date().toISOString().split("T")[0]}\n\n`;

  if (breaking.length) {
    output += "### Breaking Changes\n";
    breaking.forEach((e) => {
      output += `- **Changed:** ${e.message.replace(/BREAKING CHANGE:\s*/, "")}\n`;
    });
    output += "\n";
  }

  if (features.length) {
    output += "### New Capabilities\n";
    features.forEach((e) => {
      output += `- **Added:** ${e.message.replace(/^feat\([^)]+\):\s*/, "")}\n`;
    });
    output += "\n";
  }

  if (fixes.length) {
    output += "### Fixes\n";
    fixes.forEach((e) => {
      output += `- **Fixed:** ${e.message.replace(/^fix\([^)]+\):\s*/, "")}\n`;
    });
    output += "\n";
  }

  if (deprecations.length) {
    output += "### Deprecations\n";
    deprecations.forEach((e) => {
      output += `- **Deprecated:** ${e.message.replace(/^deprecate\([^)]+\):\s*/, "")}\n`;
    });
  }

  return output;
}

console.log(generateChangelog("v2.4.0", "v2.5.0"));

Tools That Generate Changelogs

ToolLanguageApproach
semantic-releaseJavaScriptParses conventional commits, auto-generates releases
conventional-changelogJavaScriptCLI to generate changelogs from commit history
auto-changelogJavaScriptSimple CLI, outputs Markdown from git log
git-cliffRustConfigurable changelog generator from commits
changeloguruGoConventional commits to changelog

Deprecation Headers in Practice

Send HTTP headers in deprecated API responses so consumers can detect deprecation programmatically. Deprecation and Sunset follow RFC 8594 and the deprecation link relation points at the migration guide:

HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: true
Sunset: Sat, 01 Oct 2026 00:00:00 GMT
Link: <https://docs.example.com/api-migration>; rel="deprecation"

Checking Deprecation Headers in Client Code

import requests

response = requests.get("https://api.example.com/v1/orders", headers={
    "Authorization": "Bearer token123"
})

if response.headers.get("Deprecation") == "true":
    sunset = response.headers.get("Sunset", "unknown date")
    link = response.headers.get("Link", "")
    print(f"WARNING: This endpoint is deprecated. Sunset: {sunset}")
    print(f"Migration guide: {link}")
const response = await fetch("https://api.example.com/v1/orders", {
  headers: { Authorization: "Bearer token123" }
});

const deprecation = response.headers.get("Deprecation");
if (deprecation === "true") {
  const sunset = response.headers.get("Sunset");
  const link = response.headers.get("Link");
  console.warn(`Endpoint deprecated. Sunset: ${sunset}`);
  console.warn(`Migration guide: ${link}`);
}

Archiving and Retention

The changelog page can’t grow forever — a three-year-old API with monthly releases produces a page nobody scrolls. Set the retention rules once and document them in the versioning policy:

  • Live window: keep the last 12 months of entries on the main page. Enterprise consumers on long procurement cycles do look back that far; older entries move to an archive page linked from the header.
  • Archive format: same structure, same severity grouping, just older. Don’t downgrade archived entries to a footnote — a consumer upgrading across a year boundary reads the archive as carefully as the live page.
  • Sunset memory: keep sunset dates visible in the archive. When someone runs a client two majors behind, “deprecated v1, sunset 2026-12-01” is the information they came for.
  • Feed retention: RSS entries can drop after 90 days — the feed is for notification, the archive is for history.
  • Never rewrite published entries: correct a mistake with a new dated note (“Correction 2026-10-26: the totalAmount rename shipped in 2.5.0, not 2.4.0”), not a silent edit. Consumers diff the page; a quiet change reads as gaslighting.

Changelog Review Sign-off

Before a release ships, the entry should survive a two-minute review — run it against these questions:

  • Does every breaking change name what to do instead? “Removed X” without “use Y” is a support ticket, not a changelog entry.
  • Is every deprecation sunsetted? No date means no deadline means no migration urgency — consumers will treat it as optional forever.
  • Can a consumer who skipped three versions reconstruct what to do? Entries must compose; if v2.5 says “see the v2.4 notes”, the link better work.
  • Did anything consumer-facing ship without an entry? Diff the changelog against the actual deploy — a field that appears in the payload but not in the notes is exactly the silent change this document exists to prevent.
  • Is the entry dated and versioned in both places — header and body? A version number in the URL and a different one in the heading is a trust dent that shows up in screenshots.

The reviewer doesn’t need to understand the feature — they need to believe a consumer could act on the entry alone. That’s the bar.

A changelog that passes this review compounds: consumers learn that reading it is enough, so they actually read it, so the next breaking change lands with adoption instead of breakage. The ones that skip it train the same readers to discover changes by diffing responses — at which point the page might as well not exist. The template handles format; this review handles credibility.

For teams just adopting the practice: start with the next release, not a retroactive history. A changelog that begins today and stays disciplined beats a reconstructed one that covers the past and dies in a quarter — the format earns trust through repetition, not completeness.

Once the habit exists, the template becomes infrastructure: consumers stop asking “did anything change?” and start asking “when does 3.1 ship?” — the second question is the one that a healthy API earns from its consumers.

Changelog Format Standards

ElementStandardExample
Version numberSemantic Versioning2.5.0
Date formatISO 8601 (YYYY-MM-DD)2026-06-26
Entry verbPast tense, bolded**Added:**, **Fixed:**
Breaking change markerFirst section under each version### Breaking Changes
Deprecation sunsetISO 8601 date in parentheses(sunset: 2026-10-01)
Migration linkURL or endpoint referenceUse GET /v2/orders/{id}?expand=items

Explanation

The changelog uses a version-date-entry hierarchy so consumers can scan for their current version and see everything that changed since. Breaking changes lead — those are the ones that break consumers if missed. Each entry includes a verb (Added, Removed, Fixed, Deprecated) so consumers understand the nature of the change at a glance. The versioning policy section keeps ‘what does this number mean’ questions away.

Variants

ContextApproachNotes
Public APIFull changelog with RSS/emailConsumers self-serve, reduces support tickets
Internal APISlack + short summaryFaster, less ceremony
Partner APIEmail + migration guide linkPartners need white-glove communication
GraphQL APISchema diff + changelogUse @deprecated directive in schema alongside changelog

What Works

  1. Publish the changelog before deploying the change — not after consumers report issues
  2. Group entries by severity — breaking changes first, then capabilities, then fixes
  3. Include migration instructions with every breaking change, not just a description
  4. Link to documentation for complex new capabilities instead of explaining in the changelog
  5. Archive old versions but keep them accessible — enterprise clients may be several versions behind
  6. Use conventional commits so changelogs can be generated or verified automatically
  7. Send deprecation headers in API responses so client code can detect and warn

Common Mistakes

  1. Mixing bug fixes and breaking changes without clear categorization
  2. Writing “misc bug fixes” instead of listing specific fixes consumers care about
  3. Publishing changelogs days after the deployment — consumers are already broken
  4. Not versioning the changelog itself — if the changelog changes, consumers lose trust
  5. Forgetting to document removed fields — consumers discover missing data only in production
  6. Omitting the sunset date for deprecations — consumers don’t know how much time they’ve got
  7. Not linking to migration guides from the changelog entry
  8. Using inconsistent date formats across versions — stick to ISO 8601
  9. Burying breaking changes at the bottom of a release entry instead of listing them first
A new version release produces a changelog entry, then a deprecation notice with headers, and finally the sunset of the old version.

Further Reading

Frequently Asked Questions

How far back should the changelog go?

A year of changes is a good baseline online — enterprise consumers on long procurement cycles do look back that far. Anything older goes to an archive page.

Should I document internal-only changes?

No — keep this page consumer-facing. Refactors, CI tweaks, and infra work belong in internal release notes.

What if a bug fix changes behavior that some consumers relied on?

Documented old behavior? Breaking change. Undocumented bug? Fix — with a note: 'Previous behavior was unintentional and inconsistent with documentation.'

Should the changelog be a single file or a page per version?

A single Markdown file covers small APIs. Once releases come frequently, split per major version with an index — the page stays loadable and searchable.

How do I handle pre-release versions?

Tag them with SemVer pre-release labels (3.0.0-beta.1, 3.0.0-rc.1) and say plainly in the entry that things may still change.

Should I include performance improvements in the changelog?

List it only if a consumer notices — a lower latency guarantee, a new caching header. Invisible speed-ups stay in internal notes.

How do I automate changelog publication?

Wire it into CI on tag creation: generate the entry from commits, append it to the changelog, publish to the docs site, and notify subscribed channels.