Votera API, as shipped in the download
Votera REST API & Webhooks (v2.0)
Votera ships a JSON REST API and signed webhooks so your support desk, CRM or CI can file feedback, move items across the roadmap, and reply — without anyone opening the board.
Migrating from Canny (or Nolt / Featurebase)
Export your posts to CSV and import them with their vote counts intact. Admin → Import accepts title (required), body, board, votes, author, with the usual header-name aliases (Post Title, Description, Vote Count, …) and a dry-run report first.
Those tools export a vote count, not individual voters, while Votera stores one row per voter. The import therefore synthesizes one placeholder voter per counted vote, keyed imported:<post>:<n>, so a migrated post keeps its standing on the board on day one. Real visitors vote on top of that normally — a synthetic key can never collide with a real one.
Authentication
Authorization: Bearer apk_...
Create a key in Settings → API & Webhooks; shown once, stored hashed.
Endpoints
A key acts with its owner's role (3.2): any key reads; filing a post, replying and linking an issue need a member or an administrator (post.write); moving a status needs an administrator (post.moderate), exactly as on the agent endpoint. Otherwise the answer is 403.
| Method & path | What it does | ||
|---|---|---|---|
GET /api/ping | Verify a key | ||
GET /api/boards | Boards and statuses — the status ids you need to move things. Each board says whether it is private (3.2); a key sees every board | ||
GET /api/posts | ?board= (slug or id) · ?status= (label or id) · `?sort=top\ | new\ | trending. Each post carries identified_votes, paying_votes and value` (3.2) |
POST /api/posts | File a post as the key's user. From 3.2 it takes "fields": {"Area": "Billing"} by label (any case); a value a field does not take is a 422 and nothing is created | ||
GET /api/posts/{id} | One post, with its custom fields (3.2): {"Area": "Billing"} | ||
POST /api/posts/{id}/status | Move it across the roadmap | ||
GET /api/posts/{id}/comments | Public comments | ||
GET /api/posts/{id}/voters | Who asked (3.2): the voters your app signed in — name, email, plan, monthly value, when they voted | ||
GET /api/posts/{id}/issues | The ship loop (3.2): the issues linked to an idea, with their state (open, done, dropped) | ||
POST /api/posts/{id}/issues | Link an issue by its address: {"url": "https://github.com/you/app/issues/42"} (needs a full key and a role that can edit posts) | ||
POST /api/posts/{id}/comments | Reply as the team | ||
GET /api/openapi.json | OpenAPI 3.0 | ||
POST /api/crm/contacts | (3.2, administrator's key) One contact {"email", "plan", "mrr" or "arr", "fields": {…}} or {"contacts": [ … ]} (≤ 500). Fills in signed-in voters with that email; answers {"stored", "invalid", "voters_filled"} | ||
GET /similar?q=<title> | (3.2, public, no key) Up to five ideas like a title being typed — id, title, votes, status, board, url — from the boards the caller may see. What the submit form uses |
File a post from your helpdesk
curl -X POST https://feedback.example.com/api/posts \
-H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
-d '{"board":"feature-requests","title":"Bulk export","body":"Asked by 3 customers this week."}'
Move it when you start work
curl -X POST https://feedback.example.com/api/posts/42/status \
-H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
-d '{"status_id":3}'
status_id must be a real status (GET /api/boards lists them); send null to clear it.
What the API deliberately will NOT give you
- Author emails. A public board collects them so posters can be notified, not so an integration can build a mailing list.
author_emailis never serialized. - Internal staff notes. Notes written with the "internal" flag stay in the admin UI. The comments endpoint calls the same reader the public board uses, with internal notes excluded.
Webhooks
| Event | Fires when |
|---|---|
post.created | A post is filed (UI or API) |
post.status_changed | A post moves — payload carries previous_status_id |
comment.created | A public comment is added |
X-Votera-Event: post.status_changed
X-Votera-Signature: sha256=<hmac_sha256(raw_body, your_webhook_secret)>
Each webhook has a format (chosen when you add it). signed posts the JSON event above. slack and teams post {"text": "…"}, and discord posts {"content": "…"} — one readable line per event with a link to the idea, the body those tools' incoming webhooks accept as it stands.
Errors
| Code | Meaning |
|---|---|
401 | Missing, unknown or revoked key |
404 | No such post / board |
422 | Validation — detail names the problem |
Own It 3.0
MCP — the agent endpoint
Votera speaks MCP (Model Context Protocol) at POST /mcp, authenticated with the same revocable API keys the REST API uses. Stateless streamable-HTTP: one JSON-RPC message per request.
GET /mcp answers 405 with Allow: POST — deliberately before the API-key check, so a client probing for MCP support with no credentials learns the transport is here rather than being told 401, which would mean "no MCP endpoint". The unauthenticated hint names no tools.
| Tool | Writes? | What it does |
|---|---|---|
list_boards | no | The boards and how many open ideas each holds. |
list_posts | no | Ideas with vote counts and status. Sort by top / trending / new. |
post_detail | no | One idea, its body, its tally and its public comments. |
create_post | YES | Files an idea, through the public form's own validation. |
set_status | YES | Moves an idea to a status, by label. |
top_voted_report | no | The tally: most-wanted ideas and the status mix. |
Connect it to an assistant
Mint the key in the app first: Settings → API keys. Choose Read only when the assistant should answer questions but never change anything — the endpoint then lists only the read tools and refuses the rest by name, so a careless prompt cannot write. Full access behaves as before.
Every client needs the same three facts, and nothing in the handshake is vendor-specific:
| The address | https://feedback.example.com/mcp |
| The key | header Authorization: Bearer apk_xxxx |
| The transport | MCP over streamable HTTP, stateless |
Claude — one command, or the same URL and header as a custom connector in the desktop and web apps:
claude mcp add --transport http votera https://feedback.example.com/mcp \
--header "Authorization: Bearer apk_xxxx"
ChatGPT and the OpenAI API — one entry in the Responses API's tools array (in ChatGPT itself, the same URL and key go in as a connector):
{
"type": "mcp",
"server_label": "votera",
"server_url": "https://feedback.example.com/mcp",
"authorization": "apk_xxxx",
"require_approval": "never"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "votera", "name": "Votera", "url": "https://feedback.example.com/mcp",
"token": "apk_xxxx", "enabled": true }
]
}
**Every other client spells the same three facts differently — copy the shape from its own documentation, not from here.** VS Code is the clearest example of why: its configuration reference (read 6 September 2026) puts servers in .vscode/mcp.json under a "servers" object — *"an object that maps server names to their configurations"* — not an mcpServers array. Pasted as-is, the block above will not load there. The id, the URL and the token are what travel; the JSON around them belongs to whichever client you are configuring.
A local model, n8n, or your own code — n8n's MCP Client node takes the URL and the same Authorization: Bearer header; a model running on your own machine reaches it through any MCP client, so nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:
curl -X POST https://feedback.example.com/mcp \
-H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet: Votera implements the protocol, not an integration with a particular vendor.
⛔ There is deliberately no cast_vote tool
This was a design decision, and it is worth stating plainly: an agent cannot vote. A vote here is the entire signal — the product exists to tell you what people actually want. Votes are keyed to a browser or an address rather than a verified identity, and the rate limiter exists because a real attack of 1,239 votes per minute from one machine was measured. An authenticated voting endpoint would industrialize exactly that. An agent may read the tally and file an idea; the wanting has to come from someone who wants it.
What the endpoint refuses
create_postuses the public form's rules, so a two-character title is refused with the same sentence a visitor would see.- An unknown status names the valid ones. An agent told only "invalid" learns nothing.
post_detaildoes not return author email addresses. An agent reading a public board has no business collecting the address of everyone who filed a bug.
Verified voting
With Settings → Voting → require an email and confirm the address first, a vote is held until a link sent to that address is clicked (GET /vote/verify/{token}, single use, two-day life).
The anonymity property is preserved, and it is the point. The voter key is a salted hash of the address; the verification row keys on that same hash and has no email column. So the database can answer "did someone at this address verify?" and can never answer "which address voted for what". The audit trail redacts the voter key for the same reason.
Scheduled changelog releases
An entry whose published_at is in the future is not public yet. The public page, the widget and the release feed all read Board::changelog(true), which now compares the date against now — so a scheduled entry becomes visible through the same query an immediate one does. There is no second publish path to keep in step.
Scheduled backup
GET /backup/scheduled?t=<token>
A token-guarded URL for cron. The token is minted once in Backup, shown once, stored only as a SHA-256 hash. It writes a dated JSON backup, keeps the newest N and deletes older ones. Password hashes, API-key hashes, webhook secrets, the SMTP password, the OIDC client secret, the backup and calendar tokens and 2FA seeds are blanked — the same list /backup.json uses.
Release calendar
GET /feed/releases.ics?t=<token>
An iCalendar feed of changelog releases, published and scheduled, with stable UIDs.
Voter sign-in from your app (3.2)
Know who voted. Your app signs its user in to the board with a short-lived token; their votes then carry who asked, and Posts can sort ideas by paying voters or monthly value. Anonymous voting stays the default unless you choose "Only signed-in voters may vote".
Set up (Admin → Settings → Voter sign-in from your app): create the signing secret (shown once), put it in your app's server config, name your app, give the page on your app that starts the sign-in (the board adds ?return=/path), and switch it on.
The token is a standard JWT, HS256, signed with that secret:
| Claim | ||
|---|---|---|
id (or sub) | needed | your user's id — stable, never shown to other voters |
exp | needed | expiry, Unix seconds, at most 24 hours ahead |
email, name | optional | shown to your team on the idea, never to other voters |
plan | optional | e.g. "Pro" — Posts can filter by it |
value | optional | the customer's monthly value, a number (49 or 49.50) |
fields | optional (3.2) | extra facts, e.g. {"segment": "Enterprise", "seats": 250} — up to ten; a name starts with a letter (letters, digits, spaces, _, -, ≤ 40), a value is text, a number or true/false (≤ 120). Anything else is dropped, never refused. A token with fields replaces the voter's fields; one without keeps them. Posts can filter by them, "Who asked" and GET /api/posts/{id}/voters show them |
Send the user to https://feedback.example.com/sso?token=<jwt>&return=/b/feature-requests (return is a path on the board; anything else sends the voter to the board's home). For the widget, put the token on the embed script: <script src="https://feedback.example.com/embed.js" data-token="<jwt>"></script>.
// PHP (no library needed)
$b64 = fn ($s) => rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
$h = $b64(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$p = $b64(json_encode(['id' => (string) $user->id, 'email' => $user->email, 'name' => $user->name,
'plan' => $user->plan, 'value' => $user->mrr, 'exp' => time() + 600]));
$token = "$h.$p." . $b64(hash_hmac('sha256', "$h.$p", getenv('VOTERA_SSO_SECRET'), true));
// Node (npm i jsonwebtoken)
const token = jwt.sign({ id: String(user.id), email: user.email, name: user.name, plan: user.plan,
value: user.mrr }, process.env.VOTERA_SSO_SECRET, { algorithm: 'HS256', expiresIn: '10m' });
The board refuses a token signed with another secret, alg: none, one with no id or exp, one that has expired, and one that lives longer than a day, and says why on the page. A replaced secret stops every token signed with the old one. Voters see "Voting as … via <your app>" and can sign out of the board. GET /api/posts/{id}/voters lists who asked.
The ship loop: trackers tell the board when an issue closes (3.2)
Link an idea to the issues that build it (the idea's page, or POST /api/posts/{id}/issues). The tracker then calls one of these addresses, which answer 404 until you create the webhook secret in Settings → Ship loop. Votera never calls your tracker and stores no token for it.
| Door | Proof it checks | Counts as done | Never ships |
|---|---|---|---|
POST /hooks/issues/github | X-Hub-Signature-256: sha256=<HMAC-SHA256 of the raw body> with the webhook secret | an issue closed as completed; a pull request merged | closed as not planned; a pull request closed without merging |
POST /hooks/issues/gitlab | X-Gitlab-Token: <the webhook secret> | an issue closed; a merge request merged | a merge request closed without merging |
POST /hooks/issues/linear | Linear-Signature: <HMAC-SHA256 hex of the raw body> with Linear's signing secret, pasted in Settings; deliveries stamped more than ten minutes away are ignored | state type completed | canceled |
POST /hooks/issues/generic | X-Votera-Token: <the webhook secret> | {"issue": "SEC-12", "state": "done"} (a Jira key or any linked address; closed and shipped also mean done) | "state": "dropped" |
A reopened issue (or "state": "open") marks the link open again; the idea stays as it is. An idea ships when every issue linked to it is done: it moves to your first closed status whose name starts with "Shipped" (else the last roadmap column, as the Ship button does), post.status_changed fires, followers get the ship email, and a changelog entry is saved as a draft. An idea you have closed yourself (Shipped or Declined) is never moved. Each answer is JSON: {"ok": true, "matched": 1, "shipped": [12]}, {"ok": true, "ignored": true} for an event the loop does not use, 401 for a bad proof, 413 over 1 MB.
A Jira automation rule: when an issue transitions to Done → Send web request, POST to /hooks/issues/generic, header X-Votera-Token, custom data {"issue": "{{issue.key}}", "state": "done"}.
Connect it to Zapier, Make or n8n
Votera talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.
Before you start
- In Votera, open Settings → API & Webhooks. Mint an API key (choose Read only if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example
https://votera.yourcompany.com.
When something happens in Votera: send it to another tool
- In the other tool, create the trigger that gives you a URL to receive webhooks: - Zapier: the "Webhooks by Zapier" app, trigger Catch Hook (Webhooks by Zapier needs a paid Zapier plan). - Make: module Webhooks → Custom webhook. - n8n: the Webhook node.
- Copy that URL into Votera under Settings → API & Webhooks, and choose the event or events below.
- Make the event happen once in Votera, so the other tool receives a sample and you can map its fields.
| Event | Fires when |
|---|---|
post.created | A post is filed (UI or API) |
post.status_changed | A post moves — payload carries previous_status_id |
comment.created | A public comment is added |
Every delivery carries X-Votera-Signature, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.
When something happens elsewhere: act in Votera
- Zapier: "Webhooks by Zapier", action POST (or Custom Request).
- Make: module HTTP → Make a request.
- n8n: the HTTP Request node.
Send the request to your install's address plus the path from the Endpoints table above, with the header Authorization: Bearer <your API key> and a JSON body. A read-only key cannot write.
The endpoints that change something:
POST /api/posts: File a post as the key's user. From 3.2 it takes"fields": {"Area": "Billing"}by label (any case); a value a field does not take is a422and nothing is createdPOST /api/posts/{id}/status: Move it across the roadmapPOST /api/posts/{id}/issues: Link an issue by its address:{"url": "https://github.com/you/app/issues/42"}(needs a full key and a role that can edit posts)POST /api/posts/{id}/comments: Reply as the teamPOST /api/crm/contacts: (3.2, administrator's key) One contact{"email", "plan", "mrr" or "arr", "fields": {…}}or{"contacts": [ … ]}(≤ 500). Fills in signed-in voters with that email; answers{"stored", "invalid", "voters_filled"}
Example recipes
- post.created → a row in Google Sheets: webhook trigger on
post.created(A post is filed (UI or API)), then the Google Sheets "Create row" action with the fields you want. - post.status_changed → a message in Slack or Teams: webhook trigger on
post.status_changed(A post moves — payload carriesprevious_status_id), then the Slack or Microsoft Teams "Send message" action. - A new form entry or row elsewhere → Votera: the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling
POST /api/posts(File a post as the key's user. From 3.2 it takes"fields": {"Area": "Billing"}by label (any case); a value a field does not take is a422and nothing is created).
These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Votera's own automated tests check its API.