Skip to content

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.

Send these on POST /v1/changelog/entries or PATCH /v1/changelog/entries/<id>:

FieldWhat 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_urlAn https:// link to an image you host. See Images are links.
cta_label + cta_urlA 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.
labelsLabel slugs, such as ["mobile", "api"]. Replaces the entry’s labels. See Labels.
audiencepublic (default), signed_in, or segment with a segment rule (Builder). See Who sees an entry.
pinnedPinned entries show first in the widget and on the hosted page.
expires_atAfter 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_chatfalse 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.

Terminal window
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![Recurring settings](https://acme.com/img/recurring.png)",
"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
}'

Launch stores no images. You host them and send the link; nothing is uploaded, copied or proxied through us.

  • cover_image_url and every image in body_markdown (![alt](url) or <img src>) must be an https:// link. http://, data: and javascript: are refused with VALIDATION_ERROR.
  • Links in the text must be http(s), mailto:, a path or an #anchor. javascript:, data:, vbscript: and file: are refused.
  • Every Launch surface (feeds, hosted page, widget) shows images with referrerpolicy="no-referrer" and loading="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.
audienceWho sees itPlan
publicEveryone: widget, hosted page, feeds, subscriber emailEvery plan
signed_inOnly a widget call with a verified user_idEvery plan
segmentA verified user_id whose contact details or consents match the ruleBuilder

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 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
ListGET /v1/changelog/labelslist_changelog_labels
CreatePOST /v1/changelog/labels { name, slug?, colour? }create_changelog_label
Rename or recolourPATCH /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.

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

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.

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.

GET /v1/changelog/entries/<id>/stats (MCP get_changelog_entry_stats), or GET /v1/changelog/stats for the latest entries:

NumberWhat it countsPlan
viewsTimes the entry was shown in the widget or on the hosted pageEvery plan
reactionsPer reaction, one per personEvery plan
readerssigned_in: people with a signed user id it was shown to. anonymous: browsers that said it was new to themIndie and up
cta_clicksClicks on the entry’s buttonIndie and up
feedbackFeedback 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.

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, always null. 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_computed says 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.

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.

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.

Terminal window
npx nordva changelog # commits since the last git tag
npx nordva changelog --since v1.3.0 --version 1.4.0
npx nordva changelog --dry-run # print the draft, create nothing

The same over MCP (draft_changelog_from_commits, which takes the commit lines) and REST:

Terminal window
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.

FreeIndieBuilder
Published entries25 in totalUnlimitedUnlimited
Labels per project520100
Reactions, search, pinning, end dates, signed-in audience, read state across devicesYesYesYes
Announce one entryYesYes
Segment audienceYes
Per-entry views and reactionsYesYesYes
Readers, button clicks, feedback per entryYesYes
What happened after readingYes

A feature above your plan answers PLAN_UPGRADE_REQUIRED.

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 pathWhat for
GET /v1/changelog/widget/entriesThe 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/unseenThe 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/announcementThe 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>/clickA click on the entry’s button
POST /v1/changelog/subscribersThe 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.