Changelog widget API
Everything here is opt-in. An entry that uses none of it behaves exactly as before, and an existing embed keeps working unchanged. Every owner operation is on the REST API with a secret key, in the MCP server and in the dashboard.
Entry fields
Section titled “Entry fields”Send these on POST /v1/changelog/entries or PATCH /v1/changelog/entries/<id>:
| Field | What it does |
|---|---|
status: "draft" | Save without publishing. Nothing is public, emailed or posted until you publish it (PATCH with status: "published"). Drafts do not count towards the Free entry limit. |
cover_image_url | An https:// link to an image you host. See Images are links. |
cta_label + cta_url | A button on the entry (“Try it”). cta_url is an http(s) URL or a path on your own site, such as /settings/billing. Send both, or both null to remove it. |
labels | Label slugs, such as ["mobile", "api"]. Replaces the entry’s labels. See Labels. |
audience | public (default), signed_in, or segment with a segment rule (Builder). See Who sees an entry. |
pinned | Pinned entries show first in the widget and on the hosted page. |
expires_at | After this time the entry leaves every public surface: widget, hosted page and feeds. For time-limited news. |
announcement | { "style": "modal" | "banner" | "toast", "until": "<ISO time>" }, or null to end it. Indie and up. See Announce one entry. |
notify_chat | false keeps this entry out of Slack and Discord webhook endpoints. Default true. |
A published entry’s text is never edited. On a published entry only labels, pinned, expires_at and announcement may change: they decide how it is shown, not what it says. Anything else answers CANNOT_EDIT_PUBLISHED.
curl -X POST https://api.nordva.dev/v1/changelog/entries \ -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Recurring invoices", "body_markdown": "Set an invoice to repeat monthly or yearly.\n\n", "category": "feature", "version": "1.4.0", "cover_image_url": "https://acme.com/img/recurring-cover.png", "cta_label": "Try it", "cta_url": "/invoices/new?recurring=1", "labels": ["mobile"], "announcement": { "style": "modal", "until": "2026-10-10T00:00:00Z" }, "notify_subscribers": true }'Images are links
Section titled “Images are links”Launch stores no images. You host them and send the link; nothing is uploaded, copied or proxied through us.
cover_image_urland every image inbody_markdown(or<img src>) must be anhttps://link.http://,data:andjavascript:are refused withVALIDATION_ERROR.- Links in the text must be http(s),
mailto:, a path or an#anchor.javascript:,data:,vbscript:andfile:are refused. - Every Launch surface (feeds, hosted page, widget) shows images with
referrerpolicy="no-referrer"andloading="lazy": the visitor’s browser fetches the image straight from your host and tells it nothing about the page it was opened on. If you render entries yourself, do the same and escape HTML.
Who sees an entry
Section titled “Who sees an entry”audience | Who sees it | Plan |
|---|---|---|
public | Everyone: widget, hosted page, feeds, subscriber email | Every plan |
signed_in | Only a widget call with a verified user_id | Every plan |
segment | A verified user_id whose contact details or consents match the rule | Builder |
The rule, stated once: an entry reaches a public surface (the RSS, Atom and JSON feeds, the hosted page, the custom-domain page, a publishable-key list, a widget call without a valid user_hash) only when it is published, its audience is public, and it has not passed expires_at. A publishable key on GET /v1/changelog/entries sees exactly that set, and nothing else.
Signed-in and segment entries are never emailed to subscribers (subscribers are anyone who typed an address). A segment entry never goes to the in-app bell, because a broadcast reaches every signed-in user. A signed-in entry can go to the bell, with no link.
A segment rule matches on contact details and consents. The widget knows the person by the signed user id; the user id is joined to their email through identify(), and the email to their details and consents. A person with no email linked matches no segment.
{ "audience": "segment", "segment": { "match": "all", "rules": [ { "type": "detail", "key": "plan", "op": "in", "value": ["pro", "team"] }, { "type": "consent", "purpose": "beta", "status": "granted" } ] }}match is all or any. A detail rule has op eq or neq (one value), in (a list), or exists (no value). A consent rule matches a purpose granted (default) or withdrawn. At most 10 rules. After a downgrade from Builder, segment entries are hidden from everyone rather than shown to all.
Labels
Section titled “Labels”Labels sit next to the five fixed categories: “Mobile”, “API”, “Billing”. Each has a name, a slug and a colour from a fixed palette (gray, red, orange, amber, green, teal, blue, indigo, purple, pink).
| REST (secret key) | MCP | |
|---|---|---|
| List | GET /v1/changelog/labels | list_changelog_labels |
| Create | POST /v1/changelog/labels { name, slug?, colour? } | create_changelog_label |
| Rename or recolour | PATCH /v1/changelog/labels/<id> | update_changelog_label |
| Delete (also from every entry and subscriber) | DELETE /v1/changelog/labels/<id> | delete_changelog_label |
Labels per project: Free 5, Indie 20, Builder 100. The widget and the hosted page filter by ?label=<slug> and search with ?q=.
Label subscriptions. A subscriber may follow some labels only: POST /v1/changelog/subscribers with labels: ["mobile"]. They are then emailed only entries carrying one of those labels; a subscriber who follows none gets every public entry. From a browser, unknown slugs are skipped and an existing subscriber’s labels are not changed (anyone can type anyone’s address). A subscriber changes their own topics with the token from their email: PUT /v1/changelog/subscribers/by-token/<token>/labels. As the owner: GET /v1/changelog/subscribers?label=mobile lists them, and PUT /v1/changelog/subscribers/<id>/labels replaces what one follows.
Read state across devices
Section titled “Read state across devices”Show the widget a signed user id and read state is kept on the server, so a person who read an entry on their laptop does not see it as new on their phone. It is the same user_hash as the notification bell, so one setup does both: user_hash = HMAC-SHA256(signing secret, user_id) as lowercase hex, computed on your server. See Identity verification for creating the secret and computing the hash.
Send user_id with user_hash in the query, the body, or the X-Nordva-User-Hash header. A user_id without a valid hash answers USER_HASH_INVALID (403); it is never treated as anonymous.
Without a user id, the widget keeps read state in the visitor’s browser (the time it last opened the widget) and asks GET /v1/changelog/widget/unseen?since=<that time>.
Reactions
Section titled “Reactions”Each entry offers reactions from a fixed set: like, celebrate, love, laugh, fire, eyes, confused, dislike. Three are shown by default (like, celebrate, dislike); choose up to five, in order, with PATCH /v1/changelog/settings { "reaction_set": [...] } (an empty list switches reactions off). Every plan.
One reaction per person per entry: a new one replaces the old. The person is the verified user_id, or, without one, client_id: a random id the widget makes and keeps in that browser. Launch stores the entry, u:<your user id> or c:<client id>, the reaction and when. Nothing else about the person.
Announce one entry
Section titled “Announce one entry”announcement: { style, until } shows the entry once to everyone who has not seen it, as a modal, a banner or a toast, until until. Indie and up. One entry per project is announced at a time: setting it on another entry ends the first, and the answer names it in meta.announcement_replaced_entry_id. Archiving the entry ends it too.
Once per person: a signed-in person who has been shown the entry (POST /v1/changelog/widget/seen) never gets it again, on any device. An anonymous browser keeps that itself: GET /v1/changelog/widget/announcement returns a once_key to store.
Per-entry numbers
Section titled “Per-entry numbers”GET /v1/changelog/entries/<id>/stats (MCP get_changelog_entry_stats), or GET /v1/changelog/stats for the latest entries:
| Number | What it counts | Plan |
|---|---|---|
views | Times the entry was shown in the widget or on the hosted page | Every plan |
reactions | Per reaction, one per person | Every plan |
readers | signed_in: people with a signed user id it was shown to. anonymous: browsers that said it was new to them | Indie and up |
cta_clicks | Clicks on the entry’s button | Indie and up |
feedback | Feedback sent with “Give feedback on this” | Indie and up |
“Give feedback on this” is the feedback widget with the entry attached: POST /v1/feedback with changelog_entry_id. The feedback lands in your inbox linked to the entry (GET /v1/feedback?changelog_entry_id=<id>). An id that is not one of your published entries is dropped and the feedback kept.
What happened after reading
Section titled “What happened after reading”GET /v1/changelog/entries/<id>/after-reading (MCP get_changelog_after_reading), Builder. Over the 14 days after the entry published, it compares:
- readers: signed-in people the entry was shown to. Only they have a user id to follow.
- non-readers: people known by user id before it published (from
identify()or an earlier widget read) who were not shown it.
A person is matched to Stripe by their user id in the customer’s metadata, or by an email identify() linked to that user id. The outcomes, as counts and rates per group:
paid: their first ever paid invoice falls in the 14 days.upgraded: they paid an invoice in the 14 days larger than the one before it.signed_up: not computed, alwaysnull. The widget knows a reader only by the user id your site gives people who already have an account, and anonymous readers are not linked to a person.not_computedsays so in the answer.
Without a Stripe connection, paid and upgraded are null too. small_numbers is true when either group has fewer than 30 people or fewer than 5 events: read nothing into those rates. People who read your changelog are often your most active users anyway, so this shows what went together, not proof the entry caused it.
Slack and Discord
Section titled “Slack and Discord”Add a webhook endpoint with format: "slack" or "discord" and the changelog.entry.published event (Webhooks). A published entry then posts as a Slack Block Kit message, or a Discord embed: title, excerpt, cover image, labels and version, the entry’s button and a link to it (a public entry only). notify_chat: false on an entry skips Slack and Discord for that entry; JSON endpoints still get the event.
On publish, then, an entry can reach four places, each opted into on the entry: the in-app bell (notify_in_app), email subscribers (notify_subscribers, public entries, matching labels), Slack and Discord (notify_chat), and your own JSON webhooks.
Drafts from your commits
Section titled “Drafts from your commits”Launch sorts commit subjects into a draft; your own AI agent or you do the writing. No AI runs on our side, and a draft is never published by these tools.
npx nordva changelog # commits since the last git tagnpx nordva changelog --since v1.3.0 --version 1.4.0npx nordva changelog --dry-run # print the draft, create nothingThe same over MCP (draft_changelog_from_commits, which takes the commit lines) and REST:
curl -X POST https://api.nordva.dev/v1/changelog/drafts \ -H "Authorization: Bearer nv_live_YOUR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "commits": ["feat(invoices): recurring invoices (#12)", "fix: PDF totals round correctly", "chore: bump deps"], "version": "1.4.0" }'Conventional commits go under New (feat), Improved (perf, refactor), Fixed (fix), Security and Breaking changes (! or BREAKING CHANGE). Housekeeping (chore, ci, build, test, style, docs), merges, reverts and version bumps are left out and counted in skipped; other lines go under “Other changes”. dry_run: true returns the draft without creating it.
| Free | Indie | Builder | |
|---|---|---|---|
| Published entries | 25 in total | Unlimited | Unlimited |
| Labels per project | 5 | 20 | 100 |
| Reactions, search, pinning, end dates, signed-in audience, read state across devices | Yes | Yes | Yes |
| Announce one entry | Yes | Yes | |
| Segment audience | Yes | ||
| Per-entry views and reactions | Yes | Yes | Yes |
| Readers, button clicks, feedback per entry | Yes | Yes | |
| What happened after reading | Yes |
A feature above your plan answers PLAN_UPGRADE_REQUIRED.
Widget embed options
Section titled “Widget embed options”The script tag, its display modes and every attribute are in Embed a changelog. If you build your own widget instead, these are the endpoints changelog.js calls, all with the publishable key (limited per IP address, CORS open to any origin), and the same user_id + user_hash or client_id identity:
| Method and path | What for |
|---|---|
GET /v1/changelog/widget/entries | The list: q, label, category, cursor, limit (up to 50). Returns entries, pinned (first page), next_cursor, unseen_count (with a user id), announcement, labels, reactions and project (show_badge, hosted_url, subscribe) |
GET /v1/changelog/widget/unseen | The launcher’s count: with a user id, or since for an anonymous browser |
POST /v1/changelog/widget/seen | { entry_ids, first_time_ids? } when entries are shown |
GET /v1/changelog/widget/announcement | The entry to announce to this person now, or null |
POST / DELETE /v1/changelog/widget/entries/<id>/reactions | { reaction } to react, or take it back |
POST /v1/changelog/widget/entries/<id>/click | A click on the entry’s button |
POST /v1/changelog/subscribers | The subscribe box, with labels |
POST /v1/feedback | ”Give feedback on this”, with changelog_entry_id |
The hosted page counts its own views and clicks without a key: POST /public/changelog/<slug>/seen and POST /public/changelog/<slug>/entries/<id>/click.
If your site sends a Content-Security-Policy header, see Content Security Policy; images need your image host in img-src.