# HiveVote Public API The Public API gives your own code access to a project's feedback, votes, comments, changelog and users. Outbound **webhooks** tell your systems when something happens, and the **MCP server** lets AI agents do the same through chat. - Base URL: `https:///api/v1` - Requests and responses are JSON (UTF-8). Field names are `snake_case`. - Available on **Colony** and **Hive** (and during the trial). Contents: [Authentication](#authentication) · [Publishable key permissions](#publishable-key-permissions) · [Rate limits and quota](#rate-limits-and-quota) · [Errors](#errors) · [Pagination](#pagination) · [Expanding relations](#expanding-relations) · [Reference](#reference) · [Webhooks](#webhooks) · [MCP server](#mcp-server) --- ## Authentication Every request sends an API key in the `X-HiveVote-Key` header: ```bash curl https://feedback.example.com/api/v1/feedback-items \ -H "X-HiveVote-Key: hv_sk_9pQx3T0bW1mZr8yKc2VnL5aE7uJd4HfG" ``` A project has two keys, both managed under **Settings → Public API**: | Key | Prefix | Use | |---|---|---| | Secret key | `hv_sk_` | Server-side only. Full access to every endpoint. Never put it in client-side code. | | Publishable key | `hv_pk_` | Safe to embed in a web page or app. Reads what the public portal shows, and performs only the actions its [permissions](#publishable-key-permissions) allow. It follows the portal's access settings: `403` while the portal is switched off, and `401` without a user session when the portal requires sign-in. | - A key is shown **once**, when it is generated or rotated. HiveVote only stores a SHA-256 hash. - **Rotating** a key replaces it at once: the old key stops working immediately (`401`). - Owners and admins generate and rotate keys. A secret key acts with admin rights, so it is revoked when the person who created it leaves the team or stops being an admin; generate a new one then. - The key decides the project. The `X-Project` header and the host name are ignored for `/api/v1`. - A secret key acts for the team. Changes are attributed to the team member who generated the key (or the project owner), unless the request names an `author_id`. - Sending the key as `Authorization: Bearer hv_…` also works, but `X-HiveVote-Key` is the documented way: with a publishable key, the bearer slot carries the user's session (below). ### Acting as a user with the publishable key Actions such as voting or commenting with a publishable key are done **as a portal user**. Send that user's portal session token next to the key: ```bash curl -X POST https://feedback.example.com/api/v1/feedback-items//votes \ -H "X-HiveVote-Key: hv_pk_…" \ -H "Authorization: Bearer " ``` The session token is the one your user gets when they sign in to the portal — with an email code, or with Custom SSO (`POST /api/auth/custom-sso` with the signed JWT returns `{ token }`). An invalid or expired session returns `401 UNAUTHORIZED`. ## Publishable key permissions Each action can be enabled for the publishable key, and optionally allowed for guests (users without a session): | Action | Controls | |---|---| | Submit Feedback | `POST /feedback-items` | | Vote on Feedback | `POST` / `DELETE /feedback-items/{id}/votes` | | Comment on Feedback | `POST /feedback-items/{id}/comments` | | React to Feedback | (no feedback reactions in HiveVote yet) | | Vote on Roadmap | (the roadmap is synced with feedback statuses; vote on the feedback item) | | Comment on Changelog | `POST /changelog/{id}/comments` | | React to Changelog | `POST` / `DELETE /changelog/{id}/reactions` | | Subscribe to Changelog | `POST` / `DELETE /changelog-subscribers` | - An action that is not enabled returns `403 FORBIDDEN` naming the action. - With **Allow Guests** off and no session token, it returns `401 UNAUTHORIZED`. - With **Allow Guests** on and no session, the action is done by a new guest account (name from `guest_name`, default "Guest"). - Every other write endpoint needs the secret key (`403 FORBIDDEN`). - A feedback item created with the publishable key is a visitor's post: `status_id`, `tags` and `launch_date` are ignored (the default status applies), only public `custom_fields` are kept, and boards closed to submissions refuse it. - Publishable keys only read what the portal shows: approved, non-hidden items, published changelog entries, non-internal comments (plus the session user's own items and comments still waiting for approval). User objects leave out `email`, `role` and `enabled`. ## Rate limits and quota | Plan | Requests per minute | Requests per month | Webhooks per project | |---|---|---|---| | Worker | API not included | — | — | | Colony | 60 | 10,000 | 5 | | Hive / trial | 120 | 50,000 | 10 | Every response carries the per-minute window: | Header | Meaning | |---|---| | `X-RateLimit-Limit` | Requests allowed per minute | | `X-RateLimit-Remaining` | Requests left in the current minute | | `X-RateLimit-Reset` | Unix time when the window resets | - Over the per-minute limit: `429 RATE_LIMITED` with a `Retry-After` header (seconds). - Monthly quota used up: `429 QUOTA_EXCEEDED` until the 1st of next month (UTC). MCP requests count toward the same monthly quota. - Requests rejected before counting (bad key, plan without the API, limits) do not use quota. - **Settings → Public API → Usage** shows the month's requests, the quota and the rate limit. ## Errors Every error has the same shape. Branch on `code`, never on `message` (written for people, may change): ```json { "error": { "code": "VALIDATION_ERROR", "message": "title is required", "fields": { "title": "Required" } } } ``` `fields` (per-field messages) and `reason` (a narrower cause) appear only where they apply. | Status | `code` | Meaning | |---|---|---| | `400` | `VALIDATION_ERROR` | The body is not valid JSON | | `401` | `INVALID_API_KEY` | Missing, malformed, rotated or unknown key | | `401` | `UNAUTHORIZED` | The action needs a user session (publishable key), or the session is invalid | | `403` | `API_DISABLED` | The project's plan doesn't include the Public API | | `403` | `FORBIDDEN` | The key may not do this (publishable key, or a disabled permission) | | `404` | `NOT_FOUND` | No such resource in this project, or not visible to this key; also unknown endpoints | | `409` | `CONFLICT` | Collides with existing state, e.g. a duplicate tag or a second key | | `422` | `VALIDATION_ERROR` | The body or a query parameter failed validation — see `fields` | | `422` | `BUSINESS_RULE_VIOLATION` | Well-formed but refused by a rule — see `reason` (e.g. `CATEGORY_NOT_FOUND`, `DOWNVOTES_DISABLED`, `SELF_MERGE`, `USER_DISABLED` for an item, vote or comment on behalf of a disabled user, `TEAM_MEMBER`, `PLAN_LIMIT` when the plan's limit is reached, e.g. its number of webhooks) | | `429` | `RATE_LIMITED` | Per-minute limit exceeded — honour `Retry-After` | | `429` | `QUOTA_EXCEEDED` | Monthly quota used up | | `500` | `INTERNAL_ERROR` | Unexpected failure | `429`, `500` and network errors are worth retrying; every other `4xx` will fail the same way again. ## Pagination **Offset pagination** (most lists): `pagination[page]` (from 1) and `pagination[perPage]` (default 25, max 100; `pagination[limit]` is accepted as an alias). ```json { "data": [ … ], "meta": { "total": 150, "page": 1, "perPage": 25, "lastPage": 6 } } ``` **Cursor pagination** (comments, votes, reactions): `pagination[cursor]` and `pagination[limit]` (default 25, max 100). Keep fetching while `meta.hasMore` is `true`, passing `meta.cursor`. ```json { "data": [ … ], "meta": { "hasMore": true, "cursor": "eyJvIjoyNX0" } } ``` **Sorting:** `sort` is a comma-separated list of fields; prefix `-` for descending (`sort=-votes_count,created_at`). Unknown fields return `422`. **Filters** use `filter[field]=value`, `filter[field][in]=a,b`, `filter[field][null]=true|false` and, for timestamps, `filter[field][gte]`, `[lte]` and `[between]=start,end`. ## Expanding relations Relations are ids by default. `expand=category,status,author,tags` returns the objects inline. Each endpoint lists what it can expand. ## Reference Every endpoint, its parameters, request body and response, generated from the schemas the API validates requests with: - **Interactive reference:** `https:///api/docs` - **OpenAPI 3.1 document:** `https:///api/v1/openapi.json` (no key needed), for code generators and API clients Endpoints marked *secret key only* refuse the publishable key (`403 FORBIDDEN`); endpoints with a permission need it enabled for the publishable key (see [above](#publishable-key-permissions)). Request bodies and query parameters are validated: a wrong type or a missing field returns `422 VALIDATION_ERROR` with a message per field in `fields`; unknown fields are ignored. A few things the reference can't show: - `POST /feedback-items` without an author makes the key's creator the author, and adds the author's upvote. - `POST /users` returns `201` when it creates the account and `200` with `existed: true` when the email already had one. - A comment is the team's only when its author is on the team: one by a customer follows moderation and can't be internal (`422`). - Team members: `id` is the team account (use it as `assignee_id`); `user` is their portal account in this project (use it as `author_id` or `voter_id`). - Changelog subscribers are emailed automatically the first time an entry goes live. - Users are personal data: only the secret key reads `/users`. Publishable keys see authors and voters as `id`, `name` and `avatar`, with the counts. ## Webhooks Manage webhooks under **Settings → Public API → Outbound Webhooks** or through the `/webhooks` endpoints (owners and admins, and the secret key). ### Events | Event | When | |---|---| | `feedback.created` | A feedback item is created (portal, widget, dashboard, API, MCP) | | `feedback.updated` | A feedback item is updated (`data.changes` lists the fields) | | `feedback.deleted` | A feedback item is deleted | | `feedback.status_changed` | Its status changes (`data.from`, `data.to`: status names) | | `feedback.archived` / `feedback.unarchived` | It is archived / restored | | `feedback.pinned` / `feedback.unpinned` | It is pinned / unpinned | | `feedback.merged` | It is merged into another item (`data.parent`) | | `feedback.vote.created` / `feedback.vote.deleted` | A vote is cast / removed (`data.vote`) | | `feedback.comment.created` | A comment is added to a feedback item. Internal (team-only) comments are delivered too, with `comment.internal: true` | | `changelog.published` | A changelog entry goes live (scheduled entries: when their time comes) | | `changelog.comment.created` | A comment is added to a changelog entry | Subscribe to `*` for all events, including ones added later. **Send test event** in the dashboard sends a `ping`. ### Delivery Each event is a `POST` with a JSON body: ```json { "id": "evt_3Kq…", "event": "feedback.status_changed", "created_at": "2026-10-19T10:12:00.000Z", "project_id": "24559934-…", "data": { "feedback_item": { … }, "from": "In Review", "to": "In Progress", "actor": { "id": "…", "name": "Mara Quill", "email": "…" } } } ``` `data.feedback_item` is a feedback item with `category`, `status`, `author` and `tags` expanded. | Header | Value | |---|---| | `X-HiveVote-Event` | The event name | | `X-HiveVote-Delivery` | Delivery id (a redelivery gets a new one; the body, with the same `id`, is unchanged) | | `X-HiveVote-Signature` | `t=,v1=` | | `User-Agent` | `HiveVote-Webhooks/1.0` | **Verifying:** compute `HMAC-SHA256(signing_secret, ".")` and compare it with `v1`; reject old timestamps (e.g. more than 5 minutes). ```js import { createHmac, timingSafeEqual } from 'node:crypto' function verify(rawBody, header, secret) { const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('='))) const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex') return Math.abs(Date.now() / 1000 - Number(t)) < 300 && timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) } ``` **Retries:** answer with a `2xx` within 10 seconds. Timeouts, network errors, `5xx`, `408` and `429` are retried after 1 minute, 5 minutes, 30 minutes and 2 hours (5 attempts in total). Any other `4xx` fails at once. Redirects are not followed. The history keeps 30 days of deliveries. **Duplicates:** an event's `id` stays the same however often it is delivered (retries, redeliveries, and the rare case of a delivery sent twice after a worker restart), so de-duplicate on it. **Switching off:** a webhook that is switched off drops its waiting deliveries (`cancelled` in the history); events from while it was off are not sent later. When a webhook's deliveries have failed for 3 days with no success in between (at least 20 of them), HiveVote switches it off (`disabled_reason: "failing"`) and emails the project's owner and admins. Switching it back on starts over. **Who manages webhooks:** the project's owner and admins (and the secret key). Editors see webhooks and their history without the signing secret, send test events and redeliver. ## MCP server AI agents connect to `https:///mcp/`. The exact URL is shown under **Settings → MCP Server**. - **Transport:** Streamable HTTP in stateless mode (`POST` only, JSON replies, batches of up to 20 messages), served by the official MCP SDK. Protocol versions `2024-10-07` to `2025-11-25`; a client asking for an unknown version gets `2025-11-25`. - **Errors:** an unknown tool is a JSON-RPC error (`-32602`). Invalid arguments, missing scopes and failures the agent can fix (not found, plan limits) come back as a tool result with `isError: true` and a message saying what to change. - **Sign-in:** OAuth 2.1, so clients need nothing but the URL. - Metadata is at `/.well-known/oauth-protected-resource/mcp/` and `/.well-known/oauth-authorization-server`. - Clients register dynamically (`POST /oauth/register`) and use PKCE (`S256`). - You approve the client on the consent screen, which names the project and the scopes. - Access tokens (`hv_at_…`) last an hour; refresh tokens rotate on every use. - **Headless clients** can send the project's secret API key as `Authorization: Bearer hv_sk_…` instead. - **Scopes:** `project:read`, `feedback:read`, `feedback:write`, `changelog:read`, `changelog:write`, `roadmap:read`, `users:read`, `users:write`. A tool is only offered when every scope it needs was granted. The secret key has them all. - **Tools:** - Project & activity: `whoami`, `get_project`, `get_activity_feed` - Feedback: `list_feedback`, `get_feedback`, `create_feedback`, `update_feedback`, `add_feedback_comment`, `set_feedback_vote`, `list_feedback_voters`, `merge_feedback`, `create_tag` - Changelog: `list_changelog`, `get_changelog`, `create_changelog`, `update_changelog`, `publish_changelog` - Roadmap: `list_roadmap` (mode `synced`: columns are feedback statuses) - Users & team: `list_users`, `get_user`, `create_user`, `list_team_members` - **Roles:** an OAuth connection acts with the team role of the person who approved it, never more. A viewer's connection is read-only and gets portal users without email addresses; `create_user` needs an owner or admin. - **Read-only mode** hides write tools for every connection. Turning MCP off refuses all requests and revokes every connection. Each connected agent can also be disconnected on its own. - Every MCP message counts toward the REST API's monthly quota and per-minute limit, which the two share.