REST API

Convo has a public REST API at https://api.convo.randomfact.com. Everything the maker view can do to feedback, people, replies and the changelog, a script or an agent can do too — as you, with your own workspace memberships and permissions.

The API is also the reason Convo works with an assistant at all. Every operation below is a tool on the hosted MCP server; the two surfaces are the same routes, so anything you can automate here your assistant can call directly.

Explore the API

That /docs is the API's own machine-generated reference, with an Authorize button and a Try it out box on every route. The pages you are reading now are the human documentation and live on a different host. When the two disagree, the spec is right — it is generated from the same schemas that validate each request.

Addressing

Three identifiers address everything in Convo, and they are the same ones in the URL of your product's page.

  • Workspace slug — 5 to 20 lowercase letters, digits and hyphens. acme-co.
  • Product key — 3 to 5 uppercase letters. DEMO.
  • Item key — the product key, a hyphen, and a number. DEMO-5.

So a workspace-scoped route reads /v1/workspaces/acme-co/products/DEMO/items, and the public page for the same product is convo.randomfact.com/acme-co/DEMO. There is no second addressing scheme and no per-product public slug to keep track of.

If you have an item key and nothing else, start at the slug-free entry point:

GET /v1/items/DEMO-5

It searches every workspace you belong to and answers { "matches": [{ "slug", "item" }] } — normally one entry, several only when two of your workspaces happen to share a product key. The slug it hands back is exactly what the slug-scoped routes need next, so "look at DEMO-5" is one call rather than a workspace hunt.

What you can call

Every route lives under /v1. The name in bold is the operation id, which is also the name of the matching MCP tool.

Read

Operation Route
list_products GET /v1/workspaces/{slug}/products
list_items GET /v1/workspaces/{slug}/products/{key}/items
get_item GET /v1/workspaces/{slug}/products/{key}/items/{itemKey}
list_replies GET /v1/workspaces/{slug}/products/{key}/items/{itemKey}/replies
search_items GET /v1/workspaces/{slug}/products/{key}/items/search
list_people GET /v1/workspaces/{slug}/people
get_person GET /v1/workspaces/{slug}/people/{personId}
search_people GET /v1/workspaces/{slug}/people/search
resolve_item GET /v1/items/{itemKey}

list_items filters by status, tag and personId. get_item returns the item, its whole reply thread and the people attached to it in one response. get_person returns the auto-assembled record — everything that person has said across the whole portfolio, their open items, and an awaitingReply flag that is true when they spoke last on an open thread and are still waiting. That flag is derived from the threads each time you ask, not stored, so it is only on the single-person record: list_people and search_people return the lighter summary without it, and who_needs_followup is the one call that answers it for a whole workspace.

search_items and search_people take a natural-language q and are semantic: Convo embeds the query and ranks by how close the meaning is, so "the CSV import complaints" finds items that never use those words. Each hit carries a score between 0 and 1, higher being closer. Reach for search when you are describing feedback by topic and for list_items when you are filtering by status or tag.

Triage

Operation Route
update_product PATCH /v1/workspaces/{slug}/products/{key}
update_item PATCH /v1/workspaces/{slug}/products/{key}/items/{itemKey}
update_item_status POST /v1/workspaces/{slug}/products/{key}/items/{itemKey}/status
merge_items POST /v1/workspaces/{slug}/products/{key}/items/{itemKey}/merge
reply_to_item POST /v1/workspaces/{slug}/products/{key}/items/{itemKey}/replies
tag_item POST /v1/workspaces/{slug}/products/{key}/items/{itemKey}/tags

update_product renames a product or changes its visibility between public and private — the one switch over its whole public surface, described in Workspaces, members and settings. It never deletes anything, and there is no delete-product operation: that stays behind the typed confirmation in the app.

update_item is a partial edit — send only the fields that change. The title is the one most worth editing: a title is optional when someone posts, so many are derived from the opening words of a note, and the derived one is what the board, the roadmap and the changelog will show.

merge_items folds duplicates into the item in the path; the survivor keeps the +1s and the people. tag_item replaces the whole tag set rather than adding to it.

There is no create-item operation on purpose. Items are written by the people who file them, never by a maker.

Close the loop

Operation Route
who_needs_followup GET /v1/workspaces/{slug}/followup
notify_people POST /v1/workspaces/{slug}/products/{key}/items/{itemKey}/notify
draft_changelog_entry POST /v1/workspaces/{slug}/products/{key}/items/{itemKey}/changelog

who_needs_followup is the radar: three lists covering what shipped that nobody was told about, promises that have gone stale, and threads where someone is waiting on you.

notify_people is confirm-gated. The body must carry "confirm": true — anything else is a 400 and nothing is sent. That gate is not a setting and cannot be turned off, so an agent has to come back and ask you before a single message goes out. Email delivery is still being finished, so today the call records who should hear and no mail leaves; treat the delivered flag on each recipient as "eligible", not as proof anything arrived.

draft_changelog_entry returns a headline, a body and the acknowledgment for you to read, and only writes the public entry when you pass "publish": true.

Synthesis

Operation Route
summarize_feedback GET /v1/workspaces/{slug}/products/{key}/summary

This assembles a slice of feedback plus exact counts by status and by tag, for your own model to summarize. Convo runs no summarization of its own — it hands you the material and the arithmetic, and the words are yours.

Public

Operation Route
submit_feedback POST /v1/public/{slug}/{key}/feedback
get_public_roadmap GET /v1/public/{slug}/{key}/roadmap

These two are the surface an end user's own assistant reaches, addressed by the same slug-and-key pair as the public page. They need a valid token but no workspace membership, and they are the only two operations a token on the convo.public scope may call — everything above answers 403 insufficient_scope for such a token. submit_feedback runs through the same spam and rate-limit machinery as the capture box on the page, and the unauthenticated capture endpoint behind it is what a box in your own app posts to.

Paging

list_items and list_people page with pageSize (default 50, maximum 200) and an opaque cursor. A response carries nextCursor; pass it back as cursor for the next page, and treat it as a blob — do not parse or edit it. nextCursor is null when you have reached the end.

list_products and list_replies return everything in one response and always answer nextCursor: null. search_items and search_people take limit instead (default 10, maximum 50) and do not page.

Every 4xx and 5xx answer is the same envelope:

{ "error": "Item not found.", "code": "not-found" }

Authentication

Three kinds of credential reach the same routes.

API tokens

For your own scripts, CI jobs and headless tools. A Convo personal API token is a long-lived opaque string prefixed rft_convo_, and it authenticates as you — the same identity, the same workspace memberships, the same permissions as signing in.

It is you acting as you. Do not hand one to an agent, a connector or another person; those should use OAuth instead, so access is delegated and revocable on its own.

  • Create one in product settings, at /{workspace}/{KEY}/settings, under API tokens. The tokens are personal rather than product-scoped — the section just lives there. Give it a name and, optionally, an expiry. The token is shown exactly once: Convo stores only a hash of it, so copy it before you close the panel.

  • Use it as an ordinary bearer token:

    curl -H "Authorization: Bearer rft_convo_..." \
      https://api.convo.randomfact.com/v1/workspaces/acme-co/products
    
  • Revoke it from the same list. Revocation is instant, because every request looks the token up live rather than trusting anything baked into the string. An expired token stops working the moment it expires.

Only makers can mint one. An anonymous session from a public page is refused, and so is an account that belongs to no workspace.

OAuth 2.1

For integrations, connectors and assistants. Convo runs a standard OAuth 2.1 and OpenID Connect server on the same hostname, with its discovery document at /.well-known/openid-configuration:

https://api.convo.randomfact.com/.well-known/openid-configuration
  • Public clients with PKCE. S256 is the only code challenge method; no client secret is required.
  • Dynamic client registration is open. POST /oauth/register (RFC 7591) takes no initial access token, so a client can register itself at first connect. It is rate-limited per IP, so a burst of registrations from one address gets a 429 with a retry-after header.
  • Grants: authorization code and refresh token. A refresh token rotates on each use, so a client that talks to Convo at least monthly never sees the login page again.
  • Scopes. The default scope is the full maker surface — everything you can reach yourself. The two public operations are selected by the separate convo.public scope, and that one is enforced here too: a token carrying it may call submit_feedback and get_public_roadmap and nothing else. Every other operation answers 403 with code: "permission-denied" and a WWW-Authenticate: Bearer error="insufficient_scope" header — including for a token whose user is a member, or an admin, of the workspace being asked about. Membership never widens a public-scoped token.

The Swagger UI's Authorize button drives the same flow in a browser, which is the quickest way to try a route by hand.

Firebase ID tokens

The Convo app's own credential. If you already have one in hand it works as a bearer token on the same routes, which makes it convenient for a quick script written next to the app. Anonymous sessions are rejected: the public identity ladder can post feedback from a page, but it can never reach the API.

What a token can reach

A token identifies a user, never a workspace. Nothing about your memberships or your role is baked into it.

Every request that touches a workspace re-reads your live member document. If you are removed from a workspace or your role changes, the API reflects it on your very next call, with no token to revoke and no cache to wait out.

The API never reveals whether a workspace you cannot reach exists. An unknown slug and a real workspace you are not a member of both answer:

{ "error": "Workspace not found.", "code": "not-found" }

Attribution

Writes through the API are labelled, so an item's history always distinguishes them from clicks in the app. The label rides along as via on the activity entry:

Credential Label
Personal API token pat:<token name>
Workspace integration credential integration:<connection name>
OAuth access token api:<client name>
Firebase ID token no label — first-party traffic
The embeddable widget widget

Name your tokens for the job, then, rather than for yourself: pat:nightly triage in a history reads better than pat:my token. The hosted MCP server is an OAuth client, so anything your assistant does over MCP shows up as api: and the client's own name.

A captured item keeps its label too: an item filed through the widget or an assistant carries the same string as a via field of its own, so get_item and list_items say where it came in from. Items posted on the board's own page have no via at all, which is the common case — the field is absent, not empty.

Token labels are visible to anyone who can read the product. The label is stored on the item itself and on its activity entries, and on a public product those documents are readable by anyone — not through the public page, which never shows the field, but by anyone reading the product's data directly. Treat a personal token's name and an OAuth client's name as public information, not as a secret: name a token for the job it does, never for a person or a customer, and rename it if one has already been named something you would not want read. On a private product the field is member-only, like everything else about it.

The same distinction reaches the public thread. A reply posted over OAuth is recorded as an agent reply and carries the client's name, so readers can see a machine answered; a reply over a personal token or a Firebase token is an ordinary maker reply, because that credential is you.

Worked examples

Each of these uses a personal API token in $CONVO_TOKEN against the workspace acme-co and the product DEMO. The responses are trimmed.

List what is still new

curl -s -H "Authorization: Bearer $CONVO_TOKEN" \
  "https://api.convo.randomfact.com/v1/workspaces/acme-co/products/DEMO/items?status=new&pageSize=3"
{
  "items": [
    {
      "id": "DEMO-41",
      "productId": "DEMO",
      "status": "new",
      "title": "The public board doesn't say anywhere that it's powered by Convo",
      "plusOneCount": 2,
      "replyCount": 2,
      "author": { "tier": "google", "displayName": "brave-fox-71" }
    }
  ],
  "nextCursor": "eyJ2YWx1ZXMiOltdLCJpZCI6IkRFTU8tMzgifQ"
}

Plan one of them

curl -s -X POST -H "Authorization: Bearer $CONVO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"planned"}' \
  "https://api.convo.randomfact.com/v1/workspaces/acme-co/products/DEMO/items/DEMO-5/status"
{ "status": "planned", "changed": true }

changed is false when the item was already in that status, which makes the call safe to repeat.

Say so on the thread

curl -s -X POST -H "Authorization: Bearer $CONVO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Good call — CSV import is on the roadmap now."}' \
  "https://api.convo.randomfact.com/v1/workspaces/acme-co/products/DEMO/items/DEMO-5/replies"
{ "replyId": "CNQ5sdoAKmU4f3kXWtKE" }

The reply is public straight away, on the item's page and in the feed, exactly like one typed in the maker view.

Find an item from its key alone

curl -s -H "Authorization: Bearer $CONVO_TOKEN" \
  "https://api.convo.randomfact.com/v1/items/DEMO-5"
{
  "matches": [
    {
      "slug": "acme-co",
      "item": {
        "id": "DEMO-5",
        "productId": "DEMO",
        "status": "planned",
        "title": "Bulk import from CSV",
        "plusOneCount": 5,
        "replyCount": 3
      }
    }
  ]
}

A script: what you still owe

Fifteen lines of Node that print the follow-up radar. No SDK, no dependencies.

const API = "https://api.convo.randomfact.com";
const res = await fetch(`${API}/v1/workspaces/${process.env.CONVO_WORKSPACE}/followup`, {
  headers: { authorization: `Bearer ${process.env.CONVO_TOKEN}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const report = await res.json();

for (const [bucket, label] of [
  ["shippedUnnotified", "shipped, nobody told"],
  ["stalePromises", "promised over a month ago"],
  ["repliesOwed", "waiting on you"],
]) {
  console.log(`\n${label} (${report[bucket].length})`);
  for (const item of report[bucket]) {
    console.log(`  ${item.id}  +${item.plusOneCount}  ${item.title}`);
  }
}
shipped, nobody told (7)
  API-6  +4  Markdown in replies
  APP-12  +6  Undo a status change
  APP-14  +5  Dark mode

Run it on a schedule and you have a standing answer to the only question that matters after a release.

Where to go next

  • Wire Convo into your product — where this API sits among the four ways to put Convo in front of your users, and how the loop closes back into your tracker.
  • Outbound webhooks — the other direction: Convo posts to you when feedback arrives, changes status, or gets a reply, instead of you polling these routes.
  • Getting started — if you do not have a workspace and a product yet, the slug and key the routes above want come from there.