Skip to main content
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

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

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

In a Next app, import it from @userkit/nextjs — the same component, and in proxy mode 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

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

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, 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:
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 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 audiencesegment_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.