Skip to content
VoteFirst Docs
Dashboard

API

REST 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. The twenty one endpoints in prose are on the marketing site.

It sends what a board displays publicly and nothing else, apart from the two revenue routes, which only the API key opens.

Base URL
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

The API takes two keys, and both travel as X-API-Key. Which one you send depends on where your code runs.

Your code runsSendWhat it opens
On your own server The API key Every route, the two revenue routes included. A secret, and it needs the PRO plan.
In an app or a web page The embed key Every route except the two revenue routes. It begins vf_pk_, it is public by design, and it works on any plan.

Both are in the dashboard under Share & Embed. The embed key is the first thing on the page, and Create Key makes an API key. Each endpoint in the reference names the key it takes.

An API key made on a plan below PRO is refused with not_allowed.

A first request
curl "https://app.votefirst.app/api/v3/projects/your-project-slug" \
  -H "X-API-Key: $VOTEFIRST_KEY"

The API key is a secret. It also opens every administrative route on the older v2 API, and anything shipped in an app or a page can be read out of it. An app on a phone calls this same API with the embed key.

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 API 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.

Mint an identity
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.

Every response
{ "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.

CodeStatusMeaning
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

Four things worth setting up once, because each of them decides how your client behaves on a day the network is not perfect.

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.

HeaderWhat it carries
X-RateLimit-LimitThe budget this request was counted against, which is the read one or the write one.
X-RateLimit-RemainingWhat is left in it.
X-RateLimit-ResetWhen it refills, as a Unix time.
Retry-AfterSeconds 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.

Plans start at $9 a month after a 14 day trial. Start one, see the prices or open the demo.