API
Using the API
The same board as JSON, for when you would rather build the interface yourself. If you only want a working roadmap in a page, the embed script is less work and does more.
It sends what a board displays publicly and nothing else: no revenue figures, no voter identifiers, no other project's data.
https://app.votefirst.app/api/v3
Everything in the reference is generated from the same description a client generator reads, so the two cannot disagree: openapi.json.
Authentication
Create a project API key in the dashboard under Share & Embed, and send it on every request.
The API needs the PRO plan. A key made on a lower plan is refused with not_allowed.
curl "https://app.votefirst.app/api/v3/projects/your-project-slug" \ -H "X-API-Key: $VOTEFIRST_KEY"
The key is a secret. It opens every administrative route this project has, so call the API from a server you control. Do not put it in a web page, a single page application, or a mobile binary.
A key is checked against the project named in the path. A key belonging to another project is refused with key_wrong_project even when its owner can reach that project in the dashboard. The same key works on the older v2 API, so nothing needs regenerating.
Voter identity
Reads are anonymous. Writes need a voter identity, so that a vote belongs to somebody and can be withdrawn by that same somebody later.
Mint one once, store it, and send it as X-VF-Anon on every later request. Reusing it is what keeps a visitor's votes and comments theirs.
curl -X POST "https://app.votefirst.app/api/v3/projects/your-project-slug/identity" \ -H "X-API-Key: $VOTEFIRST_KEY"
Minting answers 201 the first time and 200 when you present a token you already hold, which renews it rather than replacing it. A client that calls this route on every cold start therefore keeps its voter's history instead of abandoning it.
If you already sign your users in, send that token as X-VF-Token instead. It must carry an exp claim, and it outranks X-VF-Anon when both are sent.
Response shape
Every response carries the same three keys. A value that does not apply is null rather than missing, so one decoder serves every route and no field has to be optional in a typed client.
{ "data": null, "meta": null, "error": null }
A list fills meta with page, per_page, total, total_pages and has_more. A failure fills error with a code, a message written for a person, and the field at fault when there is one. Timestamps are RFC 3339 in UTC throughout.
A feature's status is one of open, planned, in_progress, done, denied, archived. Treat it as extensible: a client should tolerate a member added later rather than fail to decode.
Error codes
The code is stable and safe to branch on. The message is written for a person and may change. A failed validation names the field in error.field.
| Code | Status | Meaning |
|---|---|---|
missing_key |
401 | No X-API-Key header was sent. |
invalid_key |
401 | The key does not exist, or it has been regenerated since you copied it. |
key_wrong_project |
403 | The key is valid but belongs to a different project than the one in the path. |
origin_not_allowed |
403 | A browser holding the project's embed key called from an origin the project's allowlist does not name. Only the embed key answers this, and only to a request that carried an Origin header. |
identity_required |
401 | The route writes something and no voter identity was sent. |
identity_invalid |
401 | The identity is malformed, expired, or was not minted for this project. |
not_allowed |
403 | The project's own settings forbid this, such as voting on a column that does not take votes. |
voter_banned |
403 | This project has banned this voter from writing. They may still read, vote and like; only comments and suggestions are refused. |
not_found |
404 | No such project, feature, comment or release. A private project answers this way too, so a 404 is not proof that something never existed. |
already_exists |
409 | The write conflicts with what is already recorded, such as voting twice. |
idempotency_in_progress |
409 | An earlier request carrying this idempotency key is still running. Wait a second and send the same request again; it will be answered with the first one's result. |
idempotency_mismatch |
409 | This idempotency key was already used for a different route. Use a new key for a new request. |
too_large |
413 | The request body is larger than one megabyte. |
validation_failed |
422 | A field failed validation. The response names it in error.field. |
rate_limited |
429 | Too many requests for this key. Wait the number of seconds in Retry-After. |
internal |
500 | The server failed to answer. Safe to retry. |
One code arrives with a status the table does not give it: withdrawing a vote you do not have answers not_found with 409, because what is missing is the vote rather than the address.
Caching, retries and limits
Caching
Every read sends an ETag. Send it back as If-None-Match and an unchanged resource answers 304 with no body. A read also carries Cache-Control: private, max-age=60, so a browser or a proxy of your own will reuse it for a minute before asking again.
Retries
Every write accepts an Idempotency-Key of your choosing, up to 200 characters. Replaying a write with the same key returns the original response, marked Idempotent-Replay: true, and creates nothing new. Records are kept for 24 hours. A write still in flight under that key answers 409 with a Retry-After.
The record is keyed on the voter as well as on the key, because the key is a value you choose: two clients on one board picking the same obvious string would otherwise be handed each other's responses.
Limits
Per API key, 1200 reads and 300 writes a minute. Over either, the request answers 429 and names the wait in Retry-After. A request body over one megabyte is refused with 413.
Every response carries where you stand, so you can slow down before you are stopped rather than after.
| Header | What it carries |
|---|---|
X-RateLimit-Limit | The budget this request was counted against, which is the read one or the write one. |
X-RateLimit-Remaining | What is left in it. |
X-RateLimit-Reset | When it refills, as a Unix time. |
Retry-After | Seconds to wait. Only on a 429 or on an idempotency conflict. |
The embed key has budgets of its own, sized for an audience rather than for one server.
Tracing a request
Every response carries an X-Request-Id, and every response carries X-API-Version: 3. Send your own X-Request-Id and it is echoed and written into the log beside ours, which is the difference between reporting that something failed and pointing at the request that did.