Home›Votera›Manual
Votera · Manual

Votera Manual

Votera — User Manual

A public feedback board: posts, votes, statuses, a roadmap and a changelog. Version 4.1.1 [src: app/src/App.php:8].

About this manual

Every statement here was written by reading Votera's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug — the markers exist so you can check rather than take our word for it.

Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out.

What Votera is

A feedback board people can post to and vote on, with statuses driving a roadmap and a changelog [src: app/src/Board.php:3].

Most of its routes are public, and that is the point: a feedback board nobody can reach without an account is not a feedback board.

Who a voter is — and who they are not

*Vote integrity is one row per post and voter, enforced by a unique index and* re-checked in code** [src: app/src/Board.php:4].

The identity behind that row is deliberately not a person:

In anonymous mode, a voter key is a random per-browser cookie token [src: app/src/Board.php:14], a year long, HTTP-only, and marked secure when the connection is [src: app/src/Board.php:26].

In email mode, the key is a salted hash of the address, never the address itself [src: app/src/Board.php:39]. The source states both consequences: one vote per email, and a leaked database still does not reveal who voted for what. The salt is the installation's own secret, so hashes are not portable between installations [src: app/src/Board.php:40].

An unrecognized mode setting fails safe to anonymous [src: app/src/Board.php:49].

Signed in from your app (3.2). When you switch on voter sign-in, your app can hand a voter to the board with a signed, short-lived token [src: app/src/VoterSso.php:51]. Their vote key is a salted hash of your app's user id [src: app/src/VoterSso.php:84], so the votes table still names nobody; the person behind that key — the name, email, plan and value your app sent — is kept in the voters table, which is exactly what lets your team see who asked for an idea [src: app/src/VoterSso.php:201]. Voters are told they are signed in and can sign out. The privacy tools export and erase that record.

⭐ Why a cookie alone is not enough — the measured attack

This is the most instructive passage in the product, and it deserves quoting rather than paraphrasing [src: app/src/Board.php:61]:

The measured attack was 1,239 votes a minute from one machine: the cookie key is client-supplied, so uniqueness on it is uniqueness of a number the attacker picks. A per-IP ceiling is what actually costs an attacker something.

That sentence names the flaw in the obvious defense. A unique index on a cookie value guarantees one vote per cookie, and the attacker chooses the cookie.

So there is a per-address rate limit applied in every mode [src: app/src/Board.php:57], defaulting to thirty votes an hour [src: app/src/Board.php:67].

The address is stored as a salted hash, never as the address [src: app/src/Board.php:57]. Old hits are pruned as the check runs [src: app/src/Board.php:76].

Two operational details worth knowing: setting the limit to zero disables it entirely, which the source notes restores the older behavior for anyone who wants it [src: app/src/Board.php:59]; and a request with no address at all — a command-line or test context — is allowed rather than blocked [src: app/src/Board.php:72].

⭐ Vote velocity, and why it exists

A stuffed board and a popular board produce the same number. The difference is how fast the votes arrived, and the source records that this signal was present but invisible [src: app/src/Board.php:86]:

votes.created_at was always recorded and never surfaced anywhere, so a stuffed board looked exactly like a popular one.

The administrator now sees votes in the last hour and the last twenty-four hours [src: app/src/Board.php:92].

That is a good example of a fix that adds no new data — the evidence was already being collected, and the defect was that nobody could see it.

Boards, statuses and the roadmap

Multiple boards are supported [src: app/src/Board.php:99], each addressable by slug [src: app/src/Board.php:100].

Statuses are yours to define [src: app/src/Board.php:144], and a status carries a flag for whether it appears on the roadmap [src: app/src/Board.php:145] — so the roadmap is a filtered view of your own workflow rather than a fixed set of columns.

Posts can be sorted, filtered by board, and optionally include closed items [src: app/src/Board.php:164].

Users and roles

Three roles, every one enumerated with no wildcard [src: app/controllers/v3.php:17]:

Permissionviewermemberadmin
post.readyesyesyes
post.write — triage: edit, comment, merge—yesyes
post.moderate——yes
changelog.write——yes
settings.write——yes

Note the split: a member can triage — edit, comment and merge — but cannot moderate [src: app/controllers/v3.php:21]. Merging duplicates is day-to-day curation; removing somebody's post is not.

Backups are administrator-only

Both backup downloads require the administrator's settings.write permission [src: app/controllers/api.php:346] [src: app/controllers/api.php:355]; before 3.1.5 they were gated by sign-in alone. A backup contains every table. On this product that includes the votes table with its voter keys and the hashed-address hit log — pseudonymous rather than personal, but still the raw material for correlating who voted for what within one installation.

Email settings

The mail server and the three notification switches have their own form on the Settings page, saved on their own [src: app/controllers/app.php:743]. Before 3.1.5 they sat inside the branding form, whose save ignored them, and saving the General form cleared them.

Chat webhooks

A webhook's format is signed JSON, or a Slack, Microsoft Teams or Discord incoming webhook, which gets one readable line per event with a link to the idea [src: app/src/Webhook.php:28].

⚠️ The agent probe names the server differently from the specification

The REST interface names the server votera [src: app/controllers/api.php:20]; the unauthenticated transport probe names it Votera [src: app/controllers/v3.php:361].

Nothing behaves differently, but a client probing with GET and one calling with POST are told two different names. Match on the lower-case value. This has been raised against the product.

Who asked (3.2)

With voter sign-in on, Posts gains two sorts — Paying voters and Monthly value — and a plan filter [src: app/src/VoterSso.php:186]. Each idea's page lists who asked, the paying ones first, with the anonymous votes counted beside them. Only signed-in voters may vote, if you choose: everyone else is sent to your app's sign-in page and brought back to the page they were on [src: app/controllers/app.php:224].

CRM data (3.2)

Settings → CRM data: import a contacts CSV exported from HubSpot, Salesforce or any CRM. The first row names the columns: Email is needed; Plan (or Tier), MRR (or Monthly value) or ARR (divided by twelve) are read when present; up to ten other columns become fields [src: app/src/Crm.php:63]. A CRM automation can send the same to POST /api/crm/contacts with an administrator's key [src: app/controllers/v32.php:585].

Votera keeps one row per email and fills in the voters your app signs in with that email — now, and whenever they sign in later: the plan, monthly value and fields their sign-in did not give. What your app's token says always wins; what the CRM filled in, a later import may refresh [src: app/src/Crm.php:131]. So "Paying voters", "Monthly value" and the segment filter work even when your app sends only an id and an email. An optional "Open in CRM" address with {email} links each voter under "Who asked" to their record. Votera stores no CRM login and calls no CRM. "Forget all CRM data" removes the contacts and what they filled in; the privacy tools export and erase a person's CRM row with the rest.

The board's language (3.2)

Settings → Board language: the public board, idea pages, roadmap, changelog, submit form, widget and their messages in English, French, German, Spanish or Polish. You pick the board's language and which others visitors may choose; a visitor's language comes from a footer link (remembered for a year), else their browser's languages among those you offer, else the board's [src: app/src/I18n.php:75]. Only the public pages follow it — the team's screens and the API stay in English [src: app/src/I18n.php:97].

Nothing people write is translated: ideas, comments, board names, status names you created and your changelog are shown as written, and no text goes to a translation service. The six status names Votera starts with, and the changelog's New / Improved / Fixed tags, follow the visitor's language as long as you keep them [src: app/src/I18n.php:117].

Custom fields (3.2)

On ideas. Settings → Idea fields: up to ten fields, each text, a number or a choice from a list [src: app/src/Fields.php:38]. You fill them in on the idea's page; a field can also be asked on the submit form (always optional for the visitor, and a visitor can set only those) and shown on the public idea page [src: app/controllers/v32.php:446]. A value a field does not take ("lots" for a number, an option not on the list) is refused by name and nothing else changes. Posts filters by a choice field's value; the CSV export has a column per field; the API reads and writes them by label.

On voters. Your app can send facts about its user in the sign-in token's fields claim — a segment, a company size [src: app/src/Fields.php:63]. They are stored with the voter (a token without fields keeps what was known; one with fields replaces it) [src: app/src/VoterSso.php:120], shown under "Who asked", returned by GET /api/posts/{id}/voters, and Posts can keep only the ideas voters with a given fact asked for [src: app/controllers/v32.php:472]. The privacy tools export and erase them with the rest of the voter profile.

Duplicates (3.2)

As a visitor types a new idea's title, the submit form asks the board for ideas like it and lists them under "Already asked? Vote there instead" [src: app/controllers/v32.php:324] — only from the boards that visitor may see. The team's Duplicates page pairs ideas that look like the same request [src: app/controllers/v32.php:376], and each idea's page lists similar ones.

The similarity is worked out on your server from the words alone: common words are dropped, simple English endings trimmed, the title counts twice, and ideas are compared by the cosine of their TF-IDF vectors, so a word most ideas share counts for little [src: app/src/Similar.php:94]. No text leaves the server. It suggests; it never merges.

Merging is a team member's choice (post.write), from the Duplicates page or an idea's page [src: app/controllers/v32.php:339]. The votes move to the idea it joins — a voter who voted for both counts once — and so do the followers, the comments and the linked issues; an internal note records the merge; the merged idea keeps its words, leaves every list, and its public address leads to the idea it joined. "Not duplicates" removes a pair from the page for good.

Private boards (3.2)

Settings → Boards → who can see it: a board can be private [src: app/controllers/v32.php:276]. The team sees it; so do the voters your app signs in, when you allow it, optionally only on the plans you list [src: app/src/Board.php:112]. To anyone else a private board and its ideas answer "not found", the same as a board that does not exist, and they are left off the home page, the board tabs, the roadmap, the sitemap and the widget [src: app/src/Board.php:134]; voting, commenting, following and posting there are refused [src: app/controllers/v32.php:269]. API keys and the agent endpoint belong to your team and see every board. One board always stays public, because it is where visitors land.

The ship loop (3.2)

An idea can be linked to the issues and pull requests that build it — GitHub, GitLab (hosted or your own), Linear, Jira, or any other address [src: app/src/ShipLoop.php:31]. Each tracker calls its own address on the board with its own proof: GitHub's signature, GitLab's token, Linear's signature, or a token header from a Jira rule [src: app/src/ShipLoop.php:88]. The addresses answer "not found" until you create the webhook secret, and Votera never calls a tracker or stores a token for one.

An idea ships when every issue linked to it is done: an issue closed as completed, a pull or merge request merged, a Linear issue completed [src: app/src/ShipLoop.php:111]. It moves to your Shipped status, followers get the ship email, and the changelog entry is saved as a draft for you to publish [src: app/controllers/v32.php:197]. An issue closed as not planned, or canceled, never ships an idea; nor does anything move an idea you have closed yourself. The same delivery twice ships once: the status change only happens if the idea is still where it was [src: app/controllers/v32.php:207]. Untick "Ship an idea when all its linked issues are done" to record the issues' state only.

Holding new ideas for review (3.3)

Public boards attract the odd spam post or an idea that should not be public. In Settings → General, tick Hold new ideas for review and an idea from the public waits for the team [src: app/controllers/app.php:196]. The person who wrote it is told it will appear once the team has looked at it. Until then nobody outside the team sees it: its page answers 404, and the board, the roadmap, similar-idea suggestions, the sitemap, the widget and Slack all leave it out. It takes no votes. Only a signed-in team member sees held ideas [src: app/src/Board.php:207]. An idea a signed-in team member posts goes straight up.

The dashboard counts held ideas under "Needs your action", and the posts list marks each one. On the idea's page an administrator presses Approve [src: app/controllers/app.php:564]; the approval is audited, and the post.created webhook goes then, not before. A member triages but does not moderate, so a member sees no Approve button and the action is refused. To turn an idea down, delete it. The team's new-idea email still arrives straight away, marked "Held for review". Ideas held in this way are not listed by the API or the AI connection until approved.

Vote from Slack (3.2)

Your team can vote without leaving Slack. In Settings → Vote from Slack, follow the three steps: create a Slack app for your workspace, add a slash command such as /votera whose request address is your Votera's /slack/command, and paste the app's signing secret. Then tick Let people vote from Slack. /votera lists the top five open ideas with their votes; /votera 12 votes for idea #12; /votera unvote 12 takes the vote back. The answer goes only to the person who typed the command [src: app/controllers/v32.php:630].

Every request is checked against the signing secret with Slack's own scheme, and one whose timestamp is more than five minutes out is refused, so an old request cannot be replayed [src: app/src/SlackVote.php:19]. Only ideas on public boards are listed or voted on: a Slack request carries no board sign-in. Each Slack person gets one vote per idea, keyed by a salted hash of their workspace and user ids [src: app/src/SlackVote.php:40]. A vote from Slack and a vote on the board count as two people. It is off until you switch it on, and Votera keeps no Slack login.

The API and agent access

A key acts with its owner's role [src: app/controllers/api.php:67]. Every key reads; filing a post and replying need post.write, moving a status needs post.moderate — the same permissions the screens and the agent tools ask for. Before 3.2 any full-scope key could write, a viewer's included.

Six tools are exposed to an agent [src: app/controllers/v3.php:191]: listing boards, listing posts, post detail and a top-voted report on the read side; creating a post and setting a status on the write side.

No tool casts a vote. An agent can raise an item and move it through your workflow; it cannot add to the count that decides what matters.

What Votera does not do

It does not store who voted for what in a form a leak would reveal [src: app/src/Board.php:39] — email mode stores a salted hash, and anonymous mode stores a random token. The one exception is the one you choose: voters your own app signs in (3.2), whose names you asked to see.

It does not store voter IP addresses [src: app/src/Board.php:57], only salted hashes of them.

It does not rely on a cookie for vote integrity [src: app/src/Board.php:61], because the cookie is the attacker's to choose.

It does not let an agent vote [src: app/controllers/v3.php:191].

It does not let a member moderate [src: app/controllers/v3.php:21].

Privacy requests: what an erase removes and what it keeps (4.1.1)

Privacy (admin) finds a person by email address: export what they wrote and what is held about them, or erase who they were. Erasing blanks their name and address on every idea and comment, deletes their voter profile, the facts your app sent about them, their CRM row and their "tell me when it ships" subscriptions. Every vote stays: the votes table never named anybody.

Kept: the ideas and comments they wrote and the answers they gave to an idea's own fields, which can name them, because the board is the record of what people asked for.

Looking after your install (from 4.0)

Updates from inside Votera

Updates in the menu opens the Updates page (/admin/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Votera never checks by itself.

When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Votera is unavailable for about a minute while this runs. The update replaces Votera's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Votera shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Votera is hosted for you, the host updates it and the page says so.

Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.

Starting with sample data

The installer offers Start with sample data: sample ideas, votes and changelog entries to try things on. You can remove it in one click. [src: app/install/index.php:110]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.

When you are done with it, press the remove button in the sample banner. It asks "Remove the sample ideas, votes and changelog entries? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the Backup & restore page (/admin/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:88]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest backups stay in data/backups/ (14 unless you change Backups to keep), with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.

To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Votera reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].

On Nginx: keep data/ private

On Apache, the .htaccess that ships with Votera keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):

location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.

A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):

server {
    listen 80;
    server_name example.com;
    root /var/www/votera/app;
    index index.php;

    location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
    location ~ ^/views/.+\.php$ { deny all; }
    location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

    location = /install/ { rewrite ^ /install/index.php last; }
    location / { try_files $uri /index.php?$query_string; }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

The Health page

Health in the menu (/admin/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].

Connect it to other tools (Zapier, Make, n8n)

Votera sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Votera on the store shows the same file [src: app/API.md].

← Back to Votera · Quickstart · API · Test run