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
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
@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.
The permalink
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”
/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: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.