# Subido API & MCP Reference

Subido is a multi-tenant public-roadmap / feedback platform (boards, voting, roadmap,
changelog). Everything in the dashboard is available over a REST API and an MCP server -
both scoped to a single **workspace** by an API key.

- REST base URL: `https://dash.subido.io/api/v1`
- MCP endpoint: `https://dash.subido.io/mcp` (JSON-RPC 2.0 over HTTP POST)
- Public portal for a workspace: `https://dash.subido.io/<workspace-slug>` (board, `/roadmap`, `/changelog`, `/changelog.rss`)
- All requests and responses are JSON (`Content-Type: application/json`), UTF-8.

## Authentication

Pass a key either way on every request:

```
Authorization: Bearer <key>
# or
X-API-Key: <key>
```

There are **two kinds of key** (create both at `https://dash.subido.io/admin/api`):

- **Workspace key** - scoped to one workspace. Acts only within it; cannot see any other.
  Ideal for handing one product's roadmap to that product's team.
- **Account key** - tied to your user; reaches **every workspace you own or belong to**.
  Use it to enumerate and manage all your products with one credential:
  1. `GET /workspaces` to list them, then
  2. send normal requests with header `X-Subido-Workspace: <slug-or-id>` to pick which one.

Missing/invalid key → `401 {"error":"..."}`. An account key that omits
`X-Subido-Workspace` on a workspace-scoped endpoint → `400` with guidance.

## Conventions

- **Errors**: non-2xx responses are `{"error":"message"}`. Validation errors are `422`,
  auth `401`, not-found `404`, method not allowed `405`, server `500`.
- **IDs** are integers. **Timestamps** are UTC `YYYY-MM-DD HH:MM:SS`.
- **Enums**:
  - Post `status`: `open`, `under_review`, `planned`, `in_progress`, `complete`, `closed`
  - Post list `sort`: `top` (votes), `new`, `trending`
  - Changelog `labels` (subset of): `new`, `improved`, `fixed`
  - Team `role`: `viewer`, `editor`, `admin` (the owner is implicit)

## Object shapes

**Post**
```json
{
  "id": 12, "title": "Dark mode", "slug": "dark-mode", "body": "<p>…</p>",
  "status": "planned", "vote_count": 41, "comment_count": 3, "is_pinned": false,
  "categories": [{"id":2,"name":"UI","slug":"ui","color":"#8a8d93"}],
  "created_at": "2026-06-09 10:00:00", "last_activity_at": "2026-06-09 12:00:00",
  "admin_url": "https://dash.subido.io/admin/p/12"
}
```

**Board**: `{id, name, slug, description, is_private, post_count, url}`
**Category**: `{id, board_id, name, slug, color}`
**Comment**: `{id, post_id, user_id, parent_id, body, is_internal, created_at, user_name, user_email, replies:[…]}`
**Changelog entry**: `{id, title, slug, body, labels:[], status, published_at, created_at, post_ids:[]}`
**Workspace**: `{id, name, slug, color_primary, logo_url, logo_dark_url, custom_domain, custom_domain_status, custom_domain_error, cname_target, portal_url}` (`custom_domain_status`: `''`|`pending`|`provisioning`|`active`|`error`)
**Stats**: `{posts, votes, comments, boards, by_status:{status:count}, top_posts:[{id,title,votes,status}]}`

---

# REST API

All paths below are relative to `https://dash.subido.io/api/v1`.

## Account & workspaces
- `GET /workspaces` - list every workspace this key can reach: `[{id, slug, name, role, board_count, post_count, portal_url}]`. With an account key that's all of them; with a workspace key it's just that one. **No `X-Subido-Workspace` needed.**
- For every other endpoint, an **account key** must say which workspace via header `X-Subido-Workspace: <slug-or-id>`. A **workspace key** ignores that header.

## Workspace & stats
- `GET /workspace` - current workspace.
- `PATCH /workspace` - update `{name?, color_primary?, logo_url?, logo_dark_url?, custom_domain?, outreach_consent_mode?}` (`optin`|`open`; `open` only applies when an own email sender is configured). Set `custom_domain` to a subdomain, then point a CNAME at `cname_target`.
- `POST /workspace/verify-domain` - verify the custom_domain's DNS and queue automatic SSL issuance; poll `custom_domain_status` (`provisioning` → `active`).
- `GET /stats` - totals + by-status counts + top posts.

## Boards
- `GET /boards` - list boards.
- `POST /boards` - `{name, description?, is_private?}` → 201 `{board}`.
- `GET /boards/{id}` - one board.
- `PATCH /boards/{id}` - `{name?, description?, is_private?, display_order?}`.
- `DELETE /boards/{id}` - delete board + its posts.

## Categories
- `GET /boards/{id}/categories` - list.
- `POST /boards/{id}/categories` - `{name, color?}` → 201 `{category}`.
- `PATCH /categories/{id}` - `{name?, color?}`.
- `DELETE /categories/{id}`.

## Posts
- `GET /boards/{id}/posts` - query: `status`, `sort` (top|new|trending), `search`, `limit` (≤100), `offset`, `internal` (bool). → `{posts:[…], total}`.
- `POST /boards/{id}/posts` - `{title, body?, status?, is_internal?, category_ids?, author_email?}`. `author_email` attributes the post to that user (created if new) and gives it a starting vote. → 201 `{post}`.
- `GET /posts/{id}` - one post.
- `PATCH /posts/{id}` - `{title?, body?, is_pinned?, is_internal?, effort?, value?, eta?, category_ids?}`.
- `DELETE /posts/{id}`.
- `POST /posts/{id}/status` - `{status, note?}`. Notifies subscribed voters; records status history.
- `POST /posts/{id}/pin` - toggles pinned. → `{is_pinned}`.
- `POST /posts/{id}/merge` - `{target_id}` - merge this post into target (sums votes without double-counting, moves comments).

## Votes (on behalf of a user)
- `POST /posts/{id}/votes` - `{email}` - add a vote for that user. → 201 `{vote_count}`.
- `DELETE /posts/{id}/votes` - `{email}` - remove it. → `{vote_count}`.

## Comments
- `GET /posts/{id}/comments` - threaded list (includes internal staff notes).
- `POST /posts/{id}/comments` - `{body, author_email?, internal?, parent_id?}` → 201 `{comment}`.

## Outreach - email a post's voters
- `GET /posts/{id}/message` - `{emailable_voters}` count (after consent + opt-out).
- `POST /posts/{id}/message` - `{subject, body, reply_to?}` - queue an email to the post's
  eligible voters. Sends via the workspace's own Postmark/SMTP if configured, else the
  platform sender. Respects consent mode + per-voter opt-out. → 201 `{queued}`.

## Roadmap
- `GET /roadmap` - columns by status: `{status: {label, posts:[…]}}`.

## Changelog
- `GET /changelog` - entries (newest first).
- `POST /changelog` - `{title, body?, labels?, post_ids?, publish?}`. If `publish:true`, it publishes and emails voters of the linked `post_ids`. → 201 `{entry}`.
- `GET /changelog/{id}` - one entry.
- `PATCH /changelog/{id}` - `{title?, body?, labels?, post_ids?, publish?}`.
- `DELETE /changelog/{id}`.
- `POST /changelog/{id}/publish` - publish a draft + notify linked voters.

## Team
- `GET /team` - `{owner, members:[…]}`.
- `POST /team` - `{email, role?}` - invite. → 201 `{member}`.
- `DELETE /team/{id}` - remove a member.

## Webhooks
- `GET /webhooks` - list.
- `POST /webhooks` - `{url, events?}` (omit `events` for all). → 201 `{webhook}` (includes the signing `secret`).
- `DELETE /webhooks/{id}`.

## Knowledge base
A workspace can run **several knowledge bases** ("Help center", "Developer docs", ...), each with its own categories, articles and portal page. The default base is served at `/<workspace>/help`; every base also at `/<workspace>/help/<base-slug>`. Article `body` is rich HTML (h2/h3, lists, links, code, blockquote, img; scripts stripped). Setting `status:"published"` makes it public. Article slugs are workspace-unique, so `/help/<article-slug>` works regardless of base.
- `GET /kb/bases` - list (with `article_count`). `POST /kb/bases` - `{name, description?, slug?}` → 201 `{base}`.
- `PATCH /kb/bases/{id}` - `{name?, description?, slug?, display_order?}`. `DELETE /kb/bases/{id}` - its content moves to the first remaining base (never deleted with it).
- `GET /kb/categories` - list (with `article_count`); optional `kb_base_id`. `POST /kb/categories` - `{name, description?, slug?, kb_base_id?}` → 201 `{category}`.
- `PATCH /kb/categories/{id}` - `{name?, description?, slug?, display_order?, kb_base_id?}` (changing `kb_base_id` moves the category and its articles). `DELETE /kb/categories/{id}` (articles kept, uncategorized).
- `GET /kb/articles` - query: `status` (draft|published), `kb_base_id`, `category_id`, `search`, `featured`, `limit`. → `{articles}` (no body).
- `POST /kb/articles` - `{title, body?, excerpt?, category_id?, kb_base_id?, status?, slug?, is_featured?}` → 201 `{article}` (a chosen category's base wins over `kb_base_id`).
- `GET /kb/articles/{id}` → `{article}` (full body). `PATCH /kb/articles/{id}` - any of the create fields. `DELETE /kb/articles/{id}`.
- `GET /kb/search?q=...&limit=...&kb_base_id=...` - full-text relevance search of published articles → `{results}`.

## Examples
```bash
KEY=rk_live_xxx

# List boards
curl -s https://dash.subido.io/api/v1/boards -H "Authorization: Bearer $KEY"

# Create a feature request on board 1
curl -s https://dash.subido.io/api/v1/boards/1/posts -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"Bulk export","body":"<p>CSV export of all posts.</p>","status":"under_review"}'

# Move a post to In Progress with a public note
curl -s https://dash.subido.io/api/v1/posts/12/status -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"status":"in_progress","note":"Building this now"}'

# Top requests
curl -s https://dash.subido.io/api/v1/stats -H "Authorization: Bearer $KEY"
```

**Using an account key across all products:**
```bash
ACCT=rk_acct_xxx

# 1. List every workspace on the account
curl -s https://dash.subido.io/api/v1/workspaces -H "Authorization: Bearer $ACCT"

# 2. Act on a specific one by adding the workspace header
curl -s https://dash.subido.io/api/v1/boards -H "Authorization: Bearer $ACCT" -H "X-Subido-Workspace: pinpic"
curl -s https://dash.subido.io/api/v1/stats  -H "Authorization: Bearer $ACCT" -H "X-Subido-Workspace: tonta"
```

---

# Webhooks

When you register a webhook, Subido POSTs a JSON body on each subscribed event:

```json
{ "event": "post.status_changed", "workspace_id": 3,
  "data": { "post_id": 12, "from": "planned", "to": "in_progress", "note": "" },
  "ts": 1781000000 }
```

Headers on every delivery:
- `X-Subido-Event: <event>`
- `X-Subido-Signature: sha256=<hex>` - HMAC-SHA256 of the **raw body** using the webhook `secret`.

Verify (pseudo):
```
expected = hmac_sha256(secret, raw_request_body)
trusted  = (header "sha256=" + expected)
```

Events: `post.created`, `post.status_changed`, `post.merged`, `vote.created`, `comment.created`.

---

# MCP server

`https://dash.subido.io/mcp` is a Model Context Protocol server (JSON-RPC 2.0 over HTTP POST), authenticated
with the same workspace API key. It lets Claude / ChatGPT run the workspace by tool call.

**Connect** (Claude or ChatGPT custom connector / MCP client):
- URL: `https://dash.subido.io/mcp/<your API key>` - the key rides in the URL, so clients that only
  accept a URL (ChatGPT connectors, claude.ai "add connector") work with no header setup.
- Header alternative (if your client supports it): URL `https://dash.subido.io/mcp` +
  `Authorization: Bearer <API key>`. A sent header takes precedence over a URL key.
- Query form also accepted: `https://dash.subido.io/mcp?key=<your API key>`.

A **workspace key** is locked to its workspace. An **account key** instead exposes a
`list_workspaces` tool and adds a required `workspace` (slug or id) argument to every other
tool - so one MCP connection can drive all your products.

**Protocol**: standard JSON-RPC. Methods: `initialize`, `tools/list`, `tools/call`, `ping`.

```bash
# initialize
curl -s https://dash.subido.io/mcp -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'

# call a tool
curl -s https://dash.subido.io/mcp -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"create_post","arguments":{"board_id":1,"title":"Dark mode"}}}'
```

## Tools (45)

**Workspace**
- `get_workspace` - name, slug, branding, portal URL.
- `update_workspace` - `{name?, color_primary?, logo_url?, logo_dark_url?, custom_domain?}`.
- `verify_domain` - verify the custom_domain DNS (CNAME → `cname_target`) and queue SSL issuance.
- `get_stats` - posts, votes, comments, counts by status, top posts.

**Boards & categories**
- `list_boards`
- `create_board` - `{name, description?, is_private?}`
- `update_board` - `{board_id, name?, description?, is_private?}`
- `delete_board` - `{board_id}`
- `list_categories` - `{board_id}`
- `create_category` - `{board_id, name, color?}`

**Posts**
- `list_posts` - `{board_id, status?, sort?, search?, limit?}`
- `get_post` - `{post_id}`
- `create_post` - `{board_id, title, body?, author_email?, status?}`
- `update_post` - `{post_id, title?, body?, is_pinned?, eta?, effort?, value?}`
- `delete_post` - `{post_id}`
- `set_status` - `{post_id, status, note?}` (notifies voters)
- `pin_post` - `{post_id}` (toggle)
- `merge_posts` - `{source_id, target_id}`
- `add_vote` - `{post_id, email}`
- `remove_vote` - `{post_id, email}`
- `list_comments` - `{post_id}`
- `add_comment` - `{post_id, body, author_email?, internal?}`

**Voter outreach**
- `outreach_audience` - `{post_id}` → how many voters are emailable + consent mode.
- `message_voters` - `{post_id, subject, body}` → email the post's eligible voters.

**Roadmap & changelog**
- `get_roadmap`
- `list_changelog`
- `create_changelog` - `{title, body?, labels?, post_ids?, publish?}`
- `update_changelog` - `{entry_id, title?, body?, labels?, post_ids?, publish?}`
- `publish_changelog` - `{entry_id}`
- `delete_changelog` - `{entry_id}`

**Team & webhooks**
- `list_team`
- `invite_member` - `{email, role?}`
- `remove_member` - `{member_id}`
- `list_webhooks`
- `create_webhook` - `{url, events?}`
- `delete_webhook` - `{webhook_id}`

**Knowledge base**
- `list_kb_bases` / `create_kb_base` - `{name, description?, slug?}` / `update_kb_base` - `{kb_base_id, ...}` / `delete_kb_base` - `{kb_base_id}` (content moves to the first remaining base)
- `list_kb_categories` - `{kb_base_id?}` / `create_kb_category` - `{name, description?, slug?, kb_base_id?}` / `update_kb_category` - `{category_id, ...}` (setting `kb_base_id` moves it + its articles) / `delete_kb_category` - `{category_id}`
- `list_kb_articles` - `{status?, kb_base_id?, category_id?, search?, featured?, limit?}`
- `get_kb_article` - `{article_id}` (full body)
- `create_kb_article` - `{title, body?, excerpt?, category_id?, kb_base_id?, status?, slug?, is_featured?}` (body is rich HTML; `status:"published"` to make it public; a chosen category's base wins)
- `update_kb_article` - `{article_id, ...}` / `delete_kb_article` - `{article_id}`
- `search_kb` - `{query, limit?, kb_base_id?}` (full-text search of published articles)

## Recipe for an agent

1. `list_boards` → pick a `board_id`.
2. `list_posts {board_id, sort:"top"}` → see what's most wanted.
3. `create_post` / `set_status` / `add_comment` to manage the board.
4. `create_changelog {publish:true, post_ids:[…]}` to ship + auto-notify voters.

---

_Generated by Subido. REST: `https://dash.subido.io/api/v1` · MCP: `https://dash.subido.io/mcp` · Keys: `https://dash.subido.io/admin/api`_