> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Feedback and the public roadmap

> A board your users write to, a roadmap anybody can read, and a ranking by the revenue behind the votes that never leaves the panel.

Feedback is the one module here whose rows are written by **your users** rather than
by you. Three surfaces sit over them, and what each one may carry is the design.

## The board your users see

```ts theme={null}
const { posts, statuses } = await userkit.getFeedbackBoard();
```

```tsx theme={null}
import { FeedbackButton } from "@userkit/react";

<FeedbackButton />;                          {/* the board expands in the flow */}
<FeedbackButton position="center" />;        {/* only the button stays in your layout */}
<FeedbackButton position="bottom-right" />;
```

`position` takes `inline` (default), `center`, `bottom-right`, `bottom-center` or
`bottom-left` — the
same five the [survey](/en/guides/surveys) takes, and one vocabulary on purpose: a
product that puts its survey in the corner and its board in the centre is describing
one layout, not two.

Inline the board expands inside the card, under the heading it belongs to. The three
floating values leave **only the button** where you put it, which is what makes them
worth having: a header or a menu has room for a button and not for a list of ideas
with a form under it. Only `center` dims the page; the corners float over a page that
stays readable and clickable, and Escape closes all three.

In a Next app, import it from `@userkit/nextjs` — the same component, and in
[proxy mode](/en/customer-auth/session-tokens) the board read, the vote and the
submission are forwarded by the handlers, with the session in an httpOnly cookie on
your own origin.

The board sits behind the verified-contact gate, for a reason the changelog's and
the survey's are not: it is not what the answer reveals, it is what the **write**
does. Submitting puts a stranger's words on a page you publish under your own name,
and a vote is a row your roadmap ranking is built from — both are assertions
anybody who knows an `external_id` could otherwise make from a browser.

Anonymous sessions pass that gate, as they do everywhere, and are then refused by
the two writes with `identity_required`: the widget mints one per page load, so a
board that counted them would be counting reloads. Reading is open to them, which
is what the public document already shows anyway.

The board tells this person which posts they voted for. It never tells them what
those voters pay.

A person may post a few requests a day; past that the API answers `429`, which is
"later" rather than "never".

## Five statuses

`open`, `planned`, `in_progress`, `shipped`, `declined` — and they travel in that
order, which is why the vocabulary is returned in the response rather than left for
a client to hard-code.

Every value that is not `open` names a different answer you gave. There is no sixth
value meaning "duplicate": that is `merged_into_id`, and merging is an act rather
than a status.

## The public roadmap

```http theme={null}
GET /v1/roadmap/{publishable_key}
```

```json theme={null}
{
  "posts": [
    { "id": "…", "title": "Bulk export", "body": "…", "status": "planned", "votes": 34, "created_at": "…" }
  ],
  "statuses": ["open", "planned", "in_progress", "shipped", "declined"],
  "max_age_seconds": 60
}
```

The ninth cacheable document, and the only one whose rows were written by your
users rather than by you — which is why what it carries is deliberately narrow: the
title, the body, the status and a **plain vote count**. Not who asked, which is your
data about your user. Not the revenue behind the votes, which never leaves the
panel at all.

`body` is **plain text written by one of your users**. Never render it as markup.
It is the one column on this plane that somebody outside your staff can put onto a
page you publish.

Every board read is bounded at 500 posts rather than paginated. That bound is a
product statement as much as a technical one — a board with five hundred live posts
has a triage problem rather than a pagination problem — and it has to be one number
across the three reads, because the ranking is computed over the set the list
returned.

## The weight

```http theme={null}
GET /v1/organization/feedback?environment=live&currency=usd&sort=weight
```

```json theme={null}
{
  "weight": {
    "currency": "usd",
    "amount_minor": 484000,
    "paying_customers": 11,
    "other_currency_customers": 4,
    "free_customers": 19
  }
}
```

A post's weight is the summed monthly value of the active subscriptions of the
**distinct customers** behind its voters — so a team of five is worth its revenue
once, not five votes. Annual prices are divided by 12, one-time prices never enter
it, and a trial enters when it ends.

**It exists on this endpoint and nowhere else.** Not on the public roadmap, not on
the contact's own board, not on a webhook. It is a separate query that only the
staff list makes, so a public response cannot acquire a weight by somebody adding a
field to a shared projection. Your users may know how many people want a thing; they
may not know what those people pay.

**The currency is an argument, never a guess.** There is no exchange table here, so
summing minor units across currencies produces a number that is neither — asking for
the weighted order without naming a currency is a `400` rather than a default. And
the answer reports how many of the customers behind each post pay in a different
currency or in nothing at all, because a weight of zero next to four
`other_currency_customers` says something completely different from a weight of zero
next to four `free_customers`. A bare number would say neither, and a reassurance is
exactly what stops somebody checking.

## Merging duplicates

```http theme={null}
POST /v1/organization/feedback/{id}/merge
```

The votes **move** to the survivor with `ON CONFLICT DO NOTHING`, so somebody who
voted for both counts once. `merged_into_id` records where the post went, and each
moved vote records which post it arrived from.

There is no un-merge endpoint, and there does not need to be: both halves of the
provenance stayed, so a wrong merge is repairable by hand. `feedback_post.merged` is
deliverable precisely because it changes the vote count of a post nobody edited — a
mirror of your board that heard only "created" and "status changed" would keep the
duplicate forever and be wrong about the total on the survivor.

## Telling the voters it shipped

Aim a [changelog post](/en/guides/changelog) at the feedback post instead of at a
segment:

```json theme={null}
{ "title": "Bulk export is here", "feedback_post_id": "…" }
```

The audience is a **join**, not a copied recipient list: somebody who votes after you
publish receives it, somebody who withdraws stops. Like any targeted post, it stays
out of the public changelog document.

## What leaves the building

`feedback_post.created`, `feedback_post.status_changed`, `feedback_post.merged` and
`feedback_vote.cast` are all deliverable as [webhooks](/en/guides/webhooks).

`status_changed` carries **both** statuses, so you act on the transition rather than
diffing two payloads — the bus is unordered, so "the previous one" is not something a
consumer can reconstruct. Setting a status a post already has announces nothing.

`feedback_vote.cast` carries the post and the contact and **never the weight**.

## Permissions

`feedback:read` is held by owner, admin **and member** — triage is support work, and
the member seat is the support agent. `feedback:write` — answering, merging, filing on
somebody's behalf — is owner and admin.

It is the one place in this module set where the read is split off from the write.
The changelog, checklists and surveys share one `engagement:manage`, because
operating what your users are shown is one act; a feedback board is something your
support desk reads all day and rarely changes.
