# Changelog widget API

> Cover images, buttons, labels, audiences, pinning, announcements, reactions, read state across devices, per-entry numbers, Slack and Discord posts, and drafts from your commits.

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

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](#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](#labels). |
| `audience` | `public` (default), `signed_in`, or `segment` with a `segment` rule (Builder). See [Who sees an entry](#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](#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`.

```bash
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
  }'
```

## Images are links

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.

## 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](/guides/contacts/). 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.

```json
{
  "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

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

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](/guides/notifications-identity/) 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

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

`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

`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

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

## Slack and Discord

Add a webhook endpoint with `format: "slack"` or `"discord"` and the `changelog.entry.published` event ([Webhooks](/guides/webhooks/#slack-and-discord)). 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

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.

```bash
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:

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

## Plans

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

The script tag, its display modes and every attribute are in [Embed a changelog](/guides/changelog-embed/#option-a-script-tag-widget-zero-code). 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](/guides/content-security-policy/); images need your image host in `img-src`.

Source: https://docs.nordva.dev/guides/changelog-widget-api/
