> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Changelog

> Release notes in three surfaces — a public page, a targeted feed and an unread badge — and why the public one drops the targeting.

A changelog post is written as a draft, aimed at everybody or at an audience, and
published by an act of its own. Three surfaces answer three different questions
about the same rows, and the differences between them are the whole design.

## Publishing is an act, not a column

```http theme={null}
POST /v1/organization/changelog/{id}/publish
```

`published_at` is a moment rather than a flag: the list is ordered by it, and the
unread badge compares a contact's arrival against it. Publishing has an endpoint of
its own because it is the step that enters a cacheable document and announces
`changelog_post.published` to your [webhooks](/en/guides/webhooks) — and a form
submitted twice must not announce a release twice. The update carries
`published_at IS NULL`, so that idempotence belongs to the database rather than to
a check somebody remembered to write.

Editing a published post is an edit; it changes the document and announces nothing.
The draft's own facts stay inside the building: an unpublished post is an
announcement you have not made yet.

## The public document

```http theme={null}
GET /v1/changelog/{publishable_key}
```

```json theme={null}
{
  "posts": [
    { "id": "…", "title": "Bulk export", "body_markdown": "…", "published_at": "2026-07-31T09:00:00Z" }
  ],
  "max_age_seconds": 60,
  "generated_at": "2026-08-02T12:00:00Z"
}
```

Cacheable, addressed by the key that already sits in your page, safe behind a CDN,
and it carries the **most recent 50 posts**. Bounded rather than paginated: a cursor
on a cacheable document multiplies the cache entries by the number of positions a
reader can be in, and what a release page renders is the recent releases. The whole
history stays available through the contact's own read, which is not cached.

**The public document drops the targeting, and that is deliberate.** A publishable
key sits in page HTML, so this document has no reader to resolve. Honouring
`segment_id` here would need an identifier the caller supplies — and a document that
answers "here is the post for this email address" is an oracle for who is in which
[segment](/en/guides/segments), answerable by anybody with a page and a list of
addresses. Segment membership is your own analysis of your users; a cacheable
document may not be probed for it.

So only a post aimed at **nobody in particular** reaches this document. A targeted
post is reachable exclusively through a contact session.

`body_markdown` is markdown as it was written. **Rendering belongs to the page that
draws it** — the API never hands you HTML, and you should never hand database text
to a markup renderer that trusts it.

## The contact's own feed

```ts theme={null}
const posts = await userkit.listChangelogPosts();
const { unread, since } = await userkit.getChangelogUnread();
await userkit.markChangelogRead(id);
```

```tsx theme={null}
import { ChangelogBadge } from "@userkit/react";

<ChangelogBadge />;
```

In a Next app, import it from `@userkit/nextjs` — the same component, and in
[proxy mode](/en/customer-auth/session-tokens) the read and the read receipt are
forwarded by the handlers, with the session in an httpOnly cookie on your own origin.
`<WhatsNew />` below and `useChangelogFeed` cross through the same door.

This is the surface that carries the targeted posts, and it sits behind the
**verified-contact gate** for exactly the reason the public document drops the
targeting. An unverified but *identified* session is an assertion anybody who knows
an `external_id` can make from a browser, and this endpoint's answer differs by
segment membership. Ungated, it would be the same oracle reached through the other
door: "does this `external_id` see the enterprise beta post" is "is this person in
the enterprise beta segment".

Anonymous sessions pass the gate, as they do everywhere — the row was created by the
same call now holding it, so there is nobody to impersonate. What they see is the
untargeted posts, with read state.

## The badge counts from when they arrived

```json theme={null}
{ "unread": 3, "since": "2026-07-15T14:02:00Z" }
```

`since` is when this contact arrived, and **nothing published before it counts**. A
badge that opens at 47 on somebody's first day is a badge they dismiss once and
never look at again. The number being small enough to act on is the only thing that
makes it a badge rather than decoration.

Marking a post read is idempotent — reading twice is reading once — and a post this
person was **never shown** answers `404` rather than letting a badge be cleared by
uuid.

## The permalink

```
GET /v1/changelog/{publishable_key}/{slug}
```

Every post has an **address**, and that is what a post gets linked by: from the widget,
from a support reply, from an email. The slug is folded from the title when the post is
written — accents folded to their base letter, because `Exportações` becoming
`exporta-es` is an address nobody can read — and it is unique **per environment**, for
the reason every `environment_id` here exists: a rehearsal in test may not take the
address its live twin was going to use.

**Changing the title does not move the page.** That is the whole promise of a
permalink: links already sent keep pointing at the post, and nothing re-sends them.
Moving the address means sending `slug` on the PATCH, deliberately, and it costs the
old address.

A draft and a targeted post both answer **404 here**, with the same sentence as a slug
that does not exist. Telling the three apart would answer "there is an unpublished post
at this address" to anybody who can guess a headline — and on a public surface the
guess is free. A post aimed at a segment **keeps its slug**: widening it to everybody
later turns that address on rather than inventing one.

The prefix for building these URLs is `changelog_url` on
`GET /v1/config/{publishable_key}` — resolved by us, because only this side knows
whether the tenant's own domain has been verified. In the contact's list each post
carries `slug` and `public`: **offer the link only when `public` is true**, or you are
offering a 404.

## "While you were away"

```tsx theme={null}
import { WhatsNew } from "@userkit/react";

<WhatsNew />;
```

The same changelog from the other end. The badge waits to be clicked; this one
**opens itself**, for the contact who is signed in, with what they have not seen —
and the ordinary render is **nothing**.

The order of the two questions is what guarantees that. It reads
`/v1/contact/changelog/unread` first, and a zero there ends it: the list is never
fetched, no dialog is mounted, and somebody who is caught up gets their page back
untouched. A modal that greets a reader with "nothing new" is a modal they learn to
dismiss before reading, and the bill for that lands on the next real announcement.

The set it shows is the counter's own set, restated on the client: unread **and**
published after `since`. The list answers with everything that contact may see,
including what predates them; the counter counts from arrival. Either the two numbers
describe the same set or one of them is lying — and the checkable one is what is on
screen. A first-day visitor's set is empty by construction.

**Being shown is what marks those posts read**, or the same dialog greets the same
person tomorrow with the same posts. The receipts are written after the dialog is on
screen, one per post drawn, and a receipt that fails costs one repeat appearance —
not a post.

And "shown" means on the screen: the center of the page holds **one dialog at a
time** — a pending agreement comes before this one, a survey after it — and nothing
is marked read while this one waits its turn. It appears as soon as the one above it
leaves, or on the next visit. The order is in [Surveys](/en/guides/surveys), which
is where the queue is explained.

Unlike the badge, the body here is rendered as **markdown**, through the help
centre's own renderer: it builds elements and never HTML, so prose arriving over the
network has nothing to inject into. A release note read in a dialog is prose somebody
wrote to be read; the badge's one-line preview had nothing to gain from headings and
lists, and this does.

## The second audience: the people who asked

A post may be aimed at a segment, or at the **voters of a feedback post**:

```json theme={null}
{ "title": "Bulk export is here", "feedback_post_id": "…" }
```

The two are mutually exclusive, and either one keeps the post out of the public
document.

The audience is a **join, not a copied recipient list**, which is what makes it
behave correctly at both ends: somebody who votes after you publish still receives
it, and somebody who withdraws their vote stops. See
[feedback and the roadmap](/en/guides/feedback) for the other half.

## What leaves the building

`changelog_post.published` is deliverable as a webhook. Its payload carries the
title and the moment and **never the audience** — `segment_id` is the one field the
public document refuses, and a webhook handing it out would be the same leak through
another door.

Editing and deleting a post are not delivered.

## Permissions

`engagement:manage` — owner and admin by default, shared with checklists and
surveys, because operating what your users are shown inside your product is one act
rather than three.
