Skip to content

REST API reference

Read and post feedback, votes, comments, screenshots and survey responses, and read the roadmap and changelog over HTTP.

Authentication

Send your key in the Authorization header on every request, whichever key it is. The key also picks the organization, so no endpoint takes an organization ID.

Authorization: Bearer fb_pub_your_public_key
Create keys in Dashboard → Project → API keys. Never ship the secret key to a browser.

User token

To act as one of your users, add their token in X-User-Token. Voting, commenting and subscribing need a token signed on your server; see server-side user signing. An unsigned token, like the one the SDK builds from its user option, only credits new feedback and survey responses to that user. It never changes a user’s saved name or email. Once a signed token is used for a user ID, unsigned tokens for that ID are ignored. For users last identified by a signed token before this protection shipped, it applies from their next signed request.

Authorization: Bearer fb_pub_your_public_key
X-User-Token: <token signed on your server>

Base URL

https://harmless-clam-802.convex.site/api/v1

Every path below is relative to this URL. It’s the same one the SDK and CLI call by default. Self-hosting? Use your own Convex deployment’s .convex.site URL instead.

Endpoints

Read feedback

GET/api/v1/feedback

Get the organization behind the key: name, branding, board settings, statuses and public tags.

Auth: Public or secret key. A public key only reads a public organization; a private one answers 403 unless you use the secret key.

Example request
curl "https://harmless-clam-802.convex.site/api/v1/feedback" \
  -H "Authorization: Bearer fb_pub_xxx"
Example response
{
  "id": "k57e4b1n8q2x9c0r3t6w5y7z1m",
  "name": "Acme",
  "slug": "acme",
  "isPublic": true,
  "primaryColor": "#5b5bd6",
  "feedbackSettings": { "defaultView": "feed", "requireApproval": false },
  "statuses": [
    { "id": "kx71…", "name": "Backlog", "color": "gray", "icon": "clock", "order": 0 },
    { "id": "kx72…", "name": "Planned", "color": "blue", "icon": "calendar", "order": 1 }
  ],
  "tags": [
    { "id": "kt31…", "name": "Bug", "slug": "bug", "color": "red" }
  ]
}

GET/api/v1/feedback/list

List approved feedback. Pinned items come first, then the chosen sort.

Auth: Public or secret key. A public key only reads a public organization; a private one answers 403 unless you use the secret key. With the secret key the response also includes internal feedback, feedback still awaiting publication, and the private fields context, assigneeId, claimedBy, githubIssueNumber, githubHtmlUrl, isInternal, publication (internal, pending, approved or rejected) and syncedFromGithub. Add a signed user token to fill hasVoted.

Example request
curl "https://harmless-clam-802.convex.site/api/v1/feedback/list?sortBy=newest&limit=10" \
  -H "Authorization: Bearer fb_pub_xxx"
Example response
{
  "items": [
    {
      "id": "jd7f2k9m1qz8x4c6v0bn3t5w",
      "title": "Add dark mode",
      "description": "Please add a dark mode option.",
      "status": "planned",
      "organizationStatus": { "id": "kx72…", "name": "Planned", "color": "blue" },
      "tags": [{ "id": "kt31…", "name": "Feature", "slug": "feature", "color": "blue" }],
      "author": { "name": "Jane Doe", "isExternal": true },
      "voteCount": 42,
      "commentCount": 5,
      "hasVoted": false,
      "isPinned": false,
      "createdAt": 1756900000000,
      "updatedAt": 1757400000000
    }
  ],
  "total": 128,
  "hasMore": true
}

GET/api/v1/feedback/item

Get one feedback item.

Auth: Public or secret key. A public key only reads a public organization; a private one answers 403 unless you use the secret key. With the secret key the response also includes internal feedback, feedback still awaiting publication, and the private fields context, assigneeId, claimedBy, githubIssueNumber, githubHtmlUrl, isInternal, publication (internal, pending, approved or rejected) and syncedFromGithub. Add a signed user token to fill hasVoted. A signed user token also fills isSubscribed.

Example request
curl "https://harmless-clam-802.convex.site/api/v1/feedback/item?id=jd7f2k9m1qz8x4c6v0bn3t5w" \
  -H "Authorization: Bearer fb_pub_xxx"
Example response
{
  "id": "jd7f2k9m1qz8x4c6v0bn3t5w",
  "title": "Add dark mode",
  "description": "Please add a dark mode option.",
  "status": "planned",
  "organizationStatus": { "id": "kx72…", "name": "Planned", "color": "blue" },
  "tags": [],
  "author": null,
  "voteCount": 42,
  "commentCount": 5,
  "hasVoted": false,
  "isSubscribed": false,
  "isPinned": false,
  "createdAt": 1756900000000,
  "updatedAt": 1757400000000
}

GET/api/v1/feedback/similar

Find up to 5 existing items with a similar title, to suggest before someone posts a duplicate.

Auth: Public or secret key. A public key only reads a public organization; a private one answers 403 unless you use the secret key.

Example request
curl "https://harmless-clam-802.convex.site/api/v1/feedback/similar?title=dark%20mode" \
  -H "Authorization: Bearer fb_pub_xxx"
Example response
[
  {
    "_id": "jd7f2k9m1qz8x4c6v0bn3t5w",
    "title": "Add dark mode",
    "status": "planned",
    "voteCount": 42
  }
]

GET/api/v1/feedback/comments

List the comments on a feedback item, with replies nested under their parent.

Auth: Public or secret key. A public key only reads a public organization; a private one answers 403 unless you use the secret key. With the secret key, authors include their email.

Example request
curl "https://harmless-clam-802.convex.site/api/v1/feedback/comments?feedbackId=jd7f2k9m1qz8x4c6v0bn3t5w" \
  -H "Authorization: Bearer fb_pub_xxx"
Example response
[
  {
    "id": "kc81…",
    "body": "This would be amazing!",
    "author": { "name": "Jane Doe", "isExternal": true },
    "isOfficial": false,
    "createdAt": 1756990000000,
    "updatedAt": 1756990000000,
    "replies": [
      {
        "id": "kc82…",
        "body": "It’s on the roadmap for next month.",
        "author": null,
        "isOfficial": true,
        "createdAt": 1757000000000,
        "updatedAt": 1757000000000
      }
    ]
  }
]

Write feedback

POST/api/v1/feedback/create

Create a feedback item.

Auth: Public or secret key; a public key can post to a private organization too. A user token, signed or not, credits the post to that user and adds their upvote. internal: true needs the secret key. Counts against the write rate limit.

Request body
{
  "title": "Add dark mode",
  "description": "Please add a dark mode option.",
  "tagId": "kt31…",
  "context": { "url": "https://app.acme.com/settings", "browser": "Chrome 128" }
}
Example request
curl -X POST "https://harmless-clam-802.convex.site/api/v1/feedback/create" \
  -H "Authorization: Bearer fb_pub_xxx" \
  -H "Content-Type: application/json" \
  -d '{"title": "Add dark mode", "description": "Please add a dark mode option."}'
Example response
{
  "feedbackId": "jd7f2k9m1qz8x4c6v0bn3t5w",
  "isApproved": false
}

Responds with 201 and isApproved: false: every new item waits for automatic triage, which publishes it shortly unless the board requires approval or the item looks like spam. Until then only the secret key reads it. Publish or reject it yourself with the secret key: POST /api/v1/admin/feedback/publication with { "feedbackId", "state": "approved" | "rejected" }. Rejecting archives the item, so it leaves every read until someone restores it.

POST/api/v1/feedback/vote

Toggle the user’s vote. Sending the same vote again removes it; sending the other type switches it.

Auth: Public or secret key, plus a signed user token in X-User-Token. Without one the request fails with 401. A public key only votes on a public organization.

Request body
{
  "feedbackId": "jd7f2k9m1qz8x4c6v0bn3t5w",
  "voteType": "upvote"
}
Example request
curl -X POST "https://harmless-clam-802.convex.site/api/v1/feedback/vote" \
  -H "Authorization: Bearer fb_pub_xxx" \
  -H "X-User-Token: $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"feedbackId": "jd7f2k9m1qz8x4c6v0bn3t5w"}'
Example response
{
  "voteCount": 43,
  "voted": true
}

POST/api/v1/feedback/comment

Comment on a feedback item as the user.

Auth: Public or secret key, plus a signed user token in X-User-Token. Without one the request fails with 401. Counts against the write rate limit.

Request body
{
  "feedbackId": "jd7f2k9m1qz8x4c6v0bn3t5w",
  "body": "We need this for late-night shifts.",
  "parentId": "kc81…"
}
Example request
curl -X POST "https://harmless-clam-802.convex.site/api/v1/feedback/comment" \
  -H "Authorization: Bearer fb_pub_xxx" \
  -H "X-User-Token: $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"feedbackId": "jd7f2k9m1qz8x4c6v0bn3t5w", "body": "We need this too."}'
Example response
{
  "id": "kc83…"
}

Responds with 201.

POST/api/v1/feedback/subscribe

Subscribe the user to updates on a feedback item.

Auth: Public or secret key, plus a signed user token in X-User-Token. Without one the request fails with 401.

Request body
{
  "feedbackId": "jd7f2k9m1qz8x4c6v0bn3t5w"
}
Example request
curl -X POST "https://harmless-clam-802.convex.site/api/v1/feedback/subscribe" \
  -H "Authorization: Bearer fb_pub_xxx" \
  -H "X-User-Token: $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"feedbackId": "jd7f2k9m1qz8x4c6v0bn3t5w"}'
Example response
{
  "subscribed": true,
  "alreadySubscribed": false
}

POST/api/v1/feedback/unsubscribe

Stop sending the user updates on a feedback item.

Auth: Public or secret key, plus a signed user token in X-User-Token. Without one the request fails with 401.

Request body
{
  "feedbackId": "jd7f2k9m1qz8x4c6v0bn3t5w"
}
Example request
curl -X POST "https://harmless-clam-802.convex.site/api/v1/feedback/unsubscribe" \
  -H "Authorization: Bearer fb_pub_xxx" \
  -H "X-User-Token: $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"feedbackId": "jd7f2k9m1qz8x4c6v0bn3t5w"}'
Example response
{
  "unsubscribed": true
}

Screenshots

POST/api/v1/feedback/screenshot/upload-url

Get a one-time URL to upload a screenshot. POST the image bytes to it with the image’s Content-Type; it answers with a storageId to pass to save.

Auth: Public or secret key. Counts against the write rate limit.

Example request
curl -X POST "https://harmless-clam-802.convex.site/api/v1/feedback/screenshot/upload-url" \
  -H "Authorization: Bearer fb_pub_xxx"
Example response
{
  "uploadUrl": "https://harmless-clam-802.convex.cloud/api/storage/upload?token=…"
}

POST/api/v1/feedback/screenshot/save

Attach an uploaded screenshot to a feedback item. The upload must be a PNG, JPEG, WebP, GIF or AVIF image of 10 MB or less, uploaded in the last 30 minutes and not attached anywhere else; type and size are read from the upload. With a public key, only the reporter (same X-User-Token user, or anonymous for anonymous reports) can attach, within 30 minutes of creating the feedback, and at most 10 screenshots per feedback item.

Auth: Public or secret key.

Request body
{
  "feedbackId": "jd7f2k9m1qz8x4c6v0bn3t5w",
  "storageId": "kg2a…",
  "width": 1440,
  "height": 900,
  "pageUrl": "https://app.acme.com/settings"
}
Example request
curl -X POST "https://harmless-clam-802.convex.site/api/v1/feedback/screenshot/save" \
  -H "Authorization: Bearer fb_pub_xxx" \
  -H "Content-Type: application/json" \
  -d '{"feedbackId": "jd7f2k9m1qz8x4c6v0bn3t5w", "storageId": "kg2a…"}'
Example response
{
  "screenshotId": "ks55…"
}

Roadmap and changelog

GET/api/v1/feedback/roadmap

Get the roadmap: one lane per status, in board order, with the approved feedback in each.

Auth: Public or secret key. A public key only reads a public organization; a private one answers 403 unless you use the secret key.

Example request
curl "https://harmless-clam-802.convex.site/api/v1/feedback/roadmap" \
  -H "Authorization: Bearer fb_pub_xxx"
Example response
{
  "lanes": [
    {
      "id": "kx72…",
      "name": "In Progress",
      "slug": "in-progress",
      "color": "purple",
      "items": [
        {
          "id": "jd7f2k9m1qz8x4c6v0bn3t5w",
          "title": "Add dark mode",
          "status": "in_progress",
          "voteCount": 42
        }
      ]
    }
  ]
}

GET/api/v1/feedback/changelog

Get published releases, newest first, with the feedback each one shipped.

Auth: Public or secret key. A public key only reads a public organization; a private one answers 403 unless you use the secret key.

Example request
curl "https://harmless-clam-802.convex.site/api/v1/feedback/changelog?limit=5" \
  -H "Authorization: Bearer fb_pub_xxx"
Example response
[
  {
    "id": "kr11…",
    "title": "Dark mode is here",
    "version": "v2.4.0",
    "description": "Dark mode now follows your system setting…",
    "publishedAt": 1757500000000,
    "items": [
      {
        "id": "jd7f2k9m1qz8x4c6v0bn3t5w",
        "title": "Add dark mode",
        "status": "completed"
      }
    ]
  }
]

Surveys

GET/api/v1/surveys/eligible

List every survey this respondent may see right now: active, inside its schedule, under its response cap, allowed by its display frequency and sampled in. Surveys with the manual trigger are included too; show them only when your app asks for them. Your client arms the triggers. The SDK and the feedback widget do it for you.

Auth: Public or secret key, for public and private organizations alike. A user token links the response to that user and keeps display frequency and sampling stable across their devices.

Example request
curl "https://harmless-clam-802.convex.site/api/v1/surveys/eligible?respondentId=visitor_8f2c" \
  -H "Authorization: Bearer fb_pub_xxx"
Example response
{
  "surveys": [
    {
      "_id": "kv91…",
      "title": "How are we doing?",
      "triggerType": "page_visit",
      "triggerConfig": { "pageUrl": "/dashboard/*", "sampleRate": 50 },
      "display": {
        "frequency": "until_completed",
        "recontactDays": 30,
        "position": "bottom_right"
      },
      "questions": [
        {
          "_id": "kq11…",
          "type": "nps",
          "title": "How likely are you to recommend us?",
          "required": true,
          "order": 0,
          "config": { "minLabel": "Not likely", "maxLabel": "Very likely" },
          "logic": [
            {
              "id": "low-score",
              "operator": "less_than",
              "value": 7,
              "target": { "kind": "question", "questionId": "kq12…" }
            }
          ],
          "next": { "kind": "ending", "endingId": "thanks" }
        },
        {
          "_id": "kq12…",
          "type": "text",
          "title": "What should we fix first?",
          "required": false,
          "order": 1
        }
      ],
      "endings": [
        { "id": "thanks", "title": "Thanks for the feedback!" }
      ]
    }
  ]
}

Questions come sorted by order, and endings and display always have values. triggerConfig.pageUrl holds comma- or newline-separated patterns with * wildcards: a pattern starting with / matches the path, anything else the full URL, and an empty value matches every page. sampleRate is a percentage from 0 to 100, delayMs applies to time_delay and eventName to event. To find the next step, the first rule in logic that matches the question’s own answer wins, then next, then the following question, then the first ending. Jumps only go forward. A statement shows a message and never takes an answer.

GET/api/v1/surveys/active

Get the first survey from eligible surveys that matches the filters, in the same shape, or null when none does. Prefer the eligible list when your client arms several triggers.

Auth: Public or secret key, for public and private organizations alike. A user token links the response to that user and keeps display frequency and sampling stable across their devices.

Example request
curl "https://harmless-clam-802.convex.site/api/v1/surveys/active?triggerType=event&respondentId=visitor_8f2c" \
  -H "Authorization: Bearer fb_pub_xxx"
Example response
{
  "_id": "kv93…",
  "title": "How was checkout?",
  "triggerType": "event",
  "triggerConfig": { "eventName": "checkout_completed" },
  "display": { "frequency": "recurring", "recontactDays": 14 },
  "questions": [
    {
      "_id": "kq21…",
      "type": "rating",
      "title": "How easy was checkout?",
      "required": true,
      "order": 0,
      "config": { "minValue": 1, "maxValue": 5, "ratingStyle": "star" }
    }
  ],
  "endings": [
    {
      "id": "default",
      "title": "Thank you!",
      "description": "Your answers have been recorded."
    }
  ]
}

POST/api/v1/surveys/respond/start

Start a response when the survey appears. Starting counts as showing the survey for its display frequency. Returns 400 when the survey isn’t active, is outside its schedule, has reached its response cap, was already shown to this respondent or leaves them out of its sample. Shares a rate limit with dismissing.

Auth: Public or secret key, for public and private organizations alike. A user token links the response to that user and keeps display frequency and sampling stable across their devices.

Request body
{
  "surveyId": "kv91…",
  "respondentId": "visitor_8f2c",
  "pageUrl": "https://app.acme.com/dashboard",
  "userAgent": "Mozilla/5.0 …"
}
Example request
curl -X POST "https://harmless-clam-802.convex.site/api/v1/surveys/respond/start" \
  -H "Authorization: Bearer fb_pub_xxx" \
  -H "Content-Type: application/json" \
  -d '{"surveyId": "kv91…", "respondentId": "visitor_8f2c"}'
Example response
{
  "responseId": "kw07…"
}

POST/api/v1/surveys/respond/answer

Save one answer while the response is in progress. Answering the same question again replaces the earlier answer, and null deletes it. Values are checked against the question: a whole number in range for NPS (0–10) and ratings, a boolean for yes/no, a listed choice for single choice, distinct listed choices for multiple choice, and text within the length limit. When the question allows “Other”, one unlisted choice is accepted as the respondent’s own text.

Auth: Public or secret key, for public and private organizations alike. A user token links the response to that user and keeps display frequency and sampling stable across their devices.

Request body
{
  "responseId": "kw07…",
  "questionId": "kq11…",
  "value": 9
}
Example request
curl -X POST "https://harmless-clam-802.convex.site/api/v1/surveys/respond/answer" \
  -H "Authorization: Bearer fb_pub_xxx" \
  -H "Content-Type: application/json" \
  -d '{"responseId": "kw07…", "questionId": "kq11…", "value": 9}'
Example response
{
  "answerId": "ka42…"
}

Returns { "answerId": null } after a null value. Invalid answers return 400, e.g. { "error": "Pick a score from 0 to 10." }, as do statements and responses that are already completed or abandoned.

POST/api/v1/surveys/respond/complete

Finish the response. Reflet walks the flow with the saved answers, deletes answers left on a branch the respondent backed out of, stores the ending reached and sends the survey.response.completed webhook. Returns 400 when a required question on that path has no answer. Completing again returns the same ending. Once completed responses reach the survey’s cap, the survey closes.

Auth: Public or secret key, for public and private organizations alike. A user token links the response to that user and keeps display frequency and sampling stable across their devices.

Request body
{
  "responseId": "kw07…"
}
Example request
curl -X POST "https://harmless-clam-802.convex.site/api/v1/surveys/respond/complete" \
  -H "Authorization: Bearer fb_pub_xxx" \
  -H "Content-Type: application/json" \
  -d '{"responseId": "kw07…"}'
Example response
{
  "success": true,
  "endingId": "thanks"
}

A daily job marks responses left unfinished for more than 24 hours as abandoned.

POST/api/v1/surveys/respond/dismiss

Mark an unfinished response as abandoned right away, e.g. when the respondent closes the survey. Does nothing to a completed response. Shares a rate limit with starting.

Auth: Public or secret key, for public and private organizations alike. A user token links the response to that user and keeps display frequency and sampling stable across their devices.

Request body
{
  "responseId": "kw07…"
}
Example request
curl -X POST "https://harmless-clam-802.convex.site/api/v1/surveys/respond/dismiss" \
  -H "Authorization: Bearer fb_pub_xxx" \
  -H "Content-Type: application/json" \
  -d '{"responseId": "kw07…"}'
Example response
{
  "success": true
}

Webhooks

Reflet POSTs a JSON payload to your endpoint when feedback changes. Add endpoints in the Webhooks section of Dashboard → Project → API keys. Each webhook has its own signing secret, shown once at creation.

Events

Request

Headers: Content-Type: application/json, User-Agent: Reflet-Webhooks/1.0, X-Reflet-Event, X-Reflet-Delivery (the same on every retry, use it to dedupe) and X-Reflet-Signature (sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the webhook secret).

data.feedback has the same shape as GET /feedback/item with a secret key, including the author’s email and the private fields.

Payload
{
  "id": "kd4n…",
  "event": "feedback.status_changed",
  "createdAt": 1757500000000,
  "organizationId": "k57e4b1n8q2x9c0r3t6w5y7z1m",
  "data": {
    "feedback": {
      "id": "js7cqbnxcv3zrgt3jj0ef3gnt17zcz79",
      "title": "Draft lost on save",
      "description": "Typing in the editor and hitting save clears the draft.",
      "status": "in_progress",
      "organizationStatus": { "id": "kx73…", "name": "In Progress", "color": "purple" },
      "tags": [{ "id": "kt32…", "name": "Bug", "slug": "bug", "color": "red" }],
      "author": { "name": "Jane Doe", "email": "jane@acme.com", "isExternal": true },
      "voteCount": 12,
      "commentCount": 3,
      "isPinned": false,
      "isInternal": false,
      "publication": "approved",
      "assigneeId": "user_42",
      "claimedBy": "agent@ci",
      "githubIssueNumber": 42,
      "githubHtmlUrl": "https://github.com/acme/app/issues/42",
      "context": { "url": "https://app.acme.com/editor", "browser": "Chrome 128" },
      "createdAt": 1757100000000,
      "updatedAt": 1757500000000
    }
  }
}

Verify the signature

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyReflet(rawBody: string, signature: string, secret: string) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  return expected.length === signature.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Respond with any 2xx within 10 seconds. A failed delivery is retried after 1 minute and again after 10 minutes. A webhook is disabled after 20 failed attempts in a row, retries included; re-enabling it resets the count. Only approved, non-internal feedback is delivered.

Rate limiting

A public key can create 30 feedback items or comments per minute, start or dismiss 600 survey responses per minute, save 3,000 survey answers or completions per minute and request 300 screenshot upload URLs per minute; each limit is separate. A secret key shares 300 writes per minute across all of these. While a key is over a limit, those requests return 429. Saving a screenshot isn’t rate limited, but a public key can attach at most 10 screenshots per feedback item. Reads, votes and subscriptions aren’t rate limited, and responses carry no rate-limit headers.

Errors

Every error response is JSON with an error field describing the issue.

{
  "error": "Feedback ID is required"
}