Public API reference
Use KyroFeedback's versioned API with a publishable key to create a custom customer feedback experience.
Public API reference
Use the public API when the hosted portal or standard widget does not fit your product. You can build your own feedback form, roadmap board, account portal, or native-web frontend. Feedback still belongs to the KyroFeedback project and can be managed from its dashboard.
Base URL and authentication
The fixed KyroFeedback API base URL is:
https://feedback.kyrocms.com/api/v1Send a bearer key with each request:
Authorization: Bearer pk_live_your_publishable_key
Content-Type: application/jsonCreate a publishable key under Dashboard → API Keys. It is project-scoped and begins with pk_live_. The API authenticates the key, checks that the project is active, enforces rate limits, and checks the browser Origin against the allowed-domain list when that list is configured.
Do not put an sk_live_ secret key in browser code. Keep secret keys only on servers you control. Never send secret keys to an AI coding agent as part of a prompt or commit one to a repository.
Example request
const KYRO_BASE_URL = "https://feedback.kyrocms.com/api/v1";
const KYRO_PUBLIC_KEY = "pk_live_your_publishable_key";
async function kyroRequest(path, options = {}) {
const response = await fetch(`${KYRO_BASE_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${KYRO_PUBLIC_KEY}`,
"Content-Type": "application/json",
...options.headers,
},
});
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(body?.error?.message || `KyroFeedback request failed (${response.status})`);
}
return body;
}
const config = await kyroRequest("/config");
const result = await kyroRequest("/feedback?limit=20");
console.log(config.data.widget.title, result.data);Responses are JSON. Most data is in data; paginated list responses also return meta.nextCursor. Errors use { "error": { "message": "...", "status": 400 } }.
Endpoint summary
| Method | Endpoint | Description |
|---|---|---|
| GET | /config | Return project name and safe widget branding; requires a publishable key |
| GET | /feedback | List and filter feedback |
| POST | /feedback | Submit anonymous feedback |
| GET | /feedback/:id | Read one feedback item in the key's project |
| GET | /feedback/:id/comments | List comments and replies |
| POST | /feedback/:id/comments | Add a comment or threaded reply |
| GET | /feedback/:id/votes | Read this browser's vote state and total count |
| POST | /feedback/:id/votes | Vote |
| DELETE | /feedback/:id/votes | Remove this browser's vote |
IDs are opaque values returned by KyroFeedback. Always URL-encode an ID before adding it to a URL.
Try the API with cURL
Replace the key with a publishable key from your project. These commands can run in a terminal; for browser requests use the JavaScript examples below.
export KYRO_KEY='pk_live_replace_me'
export KYRO_API='https://feedback.kyrocms.com/api/v1'
curl "$KYRO_API/config" \
-H "Authorization: Bearer $KYRO_KEY"
curl "$KYRO_API/feedback?limit=10&status=OPEN" \
-H "Authorization: Bearer $KYRO_KEY"
curl "$KYRO_API/feedback" \
-H "Authorization: Bearer $KYRO_KEY" \
-H 'Content-Type: application/json' \
--data '{"title":"Add keyboard shortcuts","description":"Let me navigate the editor without a mouse.","type":"FEATURE","authorName":"Alex"}'The final command creates a feedback item. Keep the key in your local shell environment; do not paste a secret key into a browser or commit it to your repository.
Read widget configuration
GET /config only accepts a publishable key. It returns safe project branding, never any secret key material:
{
"data": {
"project": { "name": "Northstar", "slug": "northstar" },
"widget": {
"title": "Feedback",
"intro": "Share an idea or let us know what we can improve.",
"buttonLabel": "Feedback",
"primaryColor": "#c46342",
"offsetX": 24,
"offsetY": 24
}
}
}These values are managed at Dashboard → Widget. A custom UI can use them as defaults or ignore them and render its own experience.
List feedback
GET /feedback accepts:
| Query parameter | Description |
|---|---|
limit | Number of results, 1–50 (default 20) |
cursor | Feedback ID from the previous page's meta.nextCursor |
q | Search title and description |
status | OPEN, PLANNED, IN_PROGRESS, COMPLETED, or CLOSED |
type | FEATURE, BUG, QUESTION, or INTEGRATION |
Example:
const page = await kyroRequest("/feedback?limit=20&status=OPEN&type=FEATURE");
const items = page.data;
if (page.meta.nextCursor) {
const next = await kyroRequest(`/feedback?limit=20&cursor=${encodeURIComponent(page.meta.nextCursor)}`);
}Each feedback record includes id, title, description, type, status, votesCount, commentsCount, createdAt, and updatedAt.
Example list response:
{
"data": [
{
"id": "cm_example_feedback_id",
"title": "Add keyboard shortcuts",
"description": "Let me navigate the editor without a mouse.",
"type": "FEATURE",
"status": "OPEN",
"votesCount": 4,
"commentsCount": 2,
"createdAt": "2026-10-04T10:15:30.000Z",
"updatedAt": "2026-10-04T10:15:30.000Z"
}
],
"meta": { "nextCursor": null }
}Submit feedback
POST /feedback requires anonymous feedback to be enabled in project Settings → Security. Signed-in dashboard users can create internal feedback through the dashboard; the public key API creates anonymous customer feedback.
await kyroRequest("/feedback", {
method: "POST",
body: JSON.stringify({
title: "Add keyboard shortcuts",
description: "I want to navigate the editor without using a mouse.",
type: "FEATURE",
authorName: "Alex", // optional display name
}),
});Title length must be 3–200 characters. Description length must be 3–10,000 characters. type must be one of the four values above. Successful creation returns 201 and the feedback record under data.
Example success response:
{
"data": {
"id": "cm_example_feedback_id",
"title": "Add keyboard shortcuts",
"description": "I want to navigate the editor without using a mouse.",
"type": "FEATURE",
"status": "OPEN",
"votesCount": 0,
"commentsCount": 0,
"createdAt": "2026-10-04T10:15:30.000Z",
"updatedAt": "2026-10-04T10:15:30.000Z"
}
}Read feedback details
GET /feedback/:id returns the matching feedback record. The API always scopes the lookup to the project associated with the key; changing the ID cannot read another project's feedback.
Comments and threaded replies
GET /feedback/:id/comments returns comments ordered by creation time. Each comment includes id, parentId, content, authorName, anonymous, createdAt, and updatedAt.
POST /feedback/:id/comments requires anonymous comments to be enabled. Add parentId to reply to an existing comment on this same feedback item. Leave it out or set it to null to create a top-level comment.
const root = await kyroRequest(`/feedback/${encodeURIComponent(feedbackId)}/comments`, {
method: "POST",
body: JSON.stringify({ content: "Thanks for the suggestion!", authorName: "Product team" }),
});
await kyroRequest(`/feedback/${encodeURIComponent(feedbackId)}/comments`, {
method: "POST",
body: JSON.stringify({
content: "Could you share an example?",
parentId: root.data.id,
}),
});Comment length is 1–5,000 characters. A parent comment from another feedback item is rejected. The API response includes the new comment under data.
The response for a new comment includes its generated ID, which you need when posting a reply:
{
"data": {
"id": "cm_example_comment_id",
"parentId": null,
"content": "Thanks for the suggestion!",
"anonymous": true,
"authorName": "Product team",
"createdAt": "2026-10-04T10:20:00.000Z",
"updatedAt": "2026-10-04T10:20:00.000Z"
}
}Comments are returned as a flat array. Build the visible tree by grouping parentId: null comments as roots and nesting each other comment under the comment whose id matches its parentId. Preserve the array order so older comments appear first. A reply can itself have replies.
Votes
GET /feedback/:id/votesreturns{ data: { voted, count } }for the anonymous browser identity and total votes.POST /feedback/:id/votescreates a vote. Anonymous voting must be enabled.DELETE /feedback/:id/votesremoves the current anonymous browser's vote.
Keep browser credentials enabled for requests so the browser sends and retains the anonymous identity cookie:
await fetch(`${KYRO_BASE_URL}/feedback/${encodeURIComponent(feedbackId)}/votes`, {
method: "POST",
credentials: "include",
headers: { Authorization: `Bearer ${KYRO_PUBLIC_KEY}` },
});The standard widget already handles this identity cookie. If you build a custom interface, use credentials: "include" consistently for API requests from the browser.
Complete browser example
This small plain HTML example creates a feedback form and list, opens a feedback detail, submits comments and threaded replies, and toggles a vote. It uses only browser APIs and the public API. Paste in your publishable key and serve the file from a host allowed by the project's domain settings.
<main>
<h1>Product feedback</h1>
<p id="message" role="status" aria-live="polite"></p>
<form id="new-feedback">
<label>Title <input name="title" required minlength="3" maxlength="200"></label>
<label>Description <textarea name="description" required minlength="3" maxlength="10000"></textarea></label>
<label>Type
<select name="type">
<option value="FEATURE">Feature</option>
<option value="BUG">Bug</option>
<option value="QUESTION">Question</option>
<option value="INTEGRATION">Integration</option>
</select>
</label>
<label>Your name (optional) <input name="authorName" maxlength="100" autocomplete="name"></label>
<button type="submit">Submit feedback</button>
</form>
<h2>Ideas</h2>
<ul id="feedback-list"></ul>
<section id="detail" hidden>
<h2 id="detail-title"></h2>
<p id="detail-description"></p>
<button id="vote" type="button">Vote</button>
<h3>Comments</h3>
<ul id="comment-list"></ul>
<form id="new-comment">
<p id="reply-label"></p>
<label>Your name (optional) <input name="authorName" maxlength="100" autocomplete="name"></label>
<label>Comment <textarea name="content" required maxlength="5000"></textarea></label>
<button type="submit">Post comment</button>
<button id="cancel-reply" type="button" hidden>Cancel reply</button>
</form>
</section>
</main>
<script>
const API = "https://feedback.kyrocms.com/api/v1";
const KEY = "pk_live_replace_me"; // Publishable key only.
const message = document.querySelector("#message");
const list = document.querySelector("#feedback-list");
const detail = document.querySelector("#detail");
let selectedFeedback = null;
let replyTo = null;
let voted = false;
let voteCount = 0;
async function api(path, options = {}) {
const response = await fetch(API + path, {
...options,
credentials: "include",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
...options.headers,
},
});
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(body?.error?.message || `Request failed (${response.status})`);
return body;
}
function showError(error) {
message.textContent = error instanceof Error ? error.message : "Something went wrong. Please try again.";
}
async function loadFeedback() {
message.textContent = "Loading feedback…";
try {
const result = await api("/feedback?limit=20");
list.replaceChildren();
for (const item of result.data) {
const row = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.textContent = `${item.title} — ${item.votesCount} votes, ${item.commentsCount} comments`;
button.addEventListener("click", () => openFeedback(item.id));
row.append(button);
list.append(row);
}
message.textContent = result.data.length ? "" : "No feedback yet. Be the first to share an idea.";
} catch (error) { showError(error); }
}
async function openFeedback(id) {
try {
const [itemResult, voteResult, commentsResult] = await Promise.all([
api(`/feedback/${encodeURIComponent(id)}`),
api(`/feedback/${encodeURIComponent(id)}/votes`),
api(`/feedback/${encodeURIComponent(id)}/comments`),
]);
selectedFeedback = itemResult.data;
voted = voteResult.data.voted;
voteCount = voteResult.data.count;
document.querySelector("#detail-title").textContent = selectedFeedback.title;
document.querySelector("#detail-description").textContent = selectedFeedback.description;
document.querySelector("#vote").textContent = `${voted ? "Remove vote" : "Vote"} (${voteCount})`;
renderComments(commentsResult.data);
detail.hidden = false;
detail.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (error) { showError(error); }
}
function renderComments(comments) {
const target = document.querySelector("#comment-list");
target.replaceChildren();
const groups = new Map();
for (const comment of comments) {
const parentKey = comment.parentId || "root";
groups.set(parentKey, [...(groups.get(parentKey) || []), comment]);
}
function appendChildren(parentId, container) {
for (const comment of groups.get(parentId) || []) {
const row = document.createElement("li");
const author = document.createElement("strong");
author.textContent = comment.authorName || "Anonymous";
const text = document.createElement("p");
text.textContent = comment.content;
const reply = document.createElement("button");
reply.type = "button";
reply.textContent = "Reply";
reply.addEventListener("click", () => {
replyTo = comment.id;
document.querySelector("#reply-label").textContent = `Replying to ${comment.authorName || "Anonymous"}`;
document.querySelector("#cancel-reply").hidden = false;
document.querySelector('#new-comment textarea[name="content"]').focus();
});
row.append(author, text, reply);
const children = document.createElement("ul");
children.style.marginInlineStart = "1.5rem";
appendChildren(comment.id, children);
if (children.childElementCount) row.append(children);
container.append(row);
}
}
appendChildren("root", target);
}
document.querySelector("#new-feedback").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const button = event.currentTarget.querySelector('button[type="submit"]');
button.disabled = true;
try {
await api("/feedback", { method: "POST", body: JSON.stringify(Object.fromEntries(form)) });
event.currentTarget.reset();
message.textContent = "Your feedback was submitted.";
await loadFeedback();
} catch (error) { showError(error); }
finally { button.disabled = false; }
});
document.querySelector("#vote").addEventListener("click", async () => {
if (!selectedFeedback) return;
try {
const result = await api(`/feedback/${encodeURIComponent(selectedFeedback.id)}/votes`, { method: voted ? "DELETE" : "POST" });
voted = result.data.voted;
voteCount = result.data.count;
document.querySelector("#vote").textContent = `${voted ? "Remove vote" : "Vote"} (${voteCount})`;
message.textContent = "";
} catch (error) { showError(error); }
});
document.querySelector("#new-comment").addEventListener("submit", async (event) => {
event.preventDefault();
if (!selectedFeedback) return;
const form = new FormData(event.currentTarget);
const body = Object.fromEntries(form);
if (replyTo) body.parentId = replyTo;
const button = event.currentTarget.querySelector('button[type="submit"]');
button.disabled = true;
try {
await api(`/feedback/${encodeURIComponent(selectedFeedback.id)}/comments`, { method: "POST", body: JSON.stringify(body) });
replyTo = null;
document.querySelector("#reply-label").textContent = "";
document.querySelector("#cancel-reply").hidden = true;
event.currentTarget.reset();
const result = await api(`/feedback/${encodeURIComponent(selectedFeedback.id)}/comments`);
renderComments(result.data);
message.textContent = "Comment posted.";
} catch (error) { showError(error); }
finally { button.disabled = false; }
});
document.querySelector("#cancel-reply").addEventListener("click", () => {
replyTo = null;
document.querySelector("#reply-label").textContent = "";
document.querySelector("#cancel-reply").hidden = true;
});
loadFeedback();
</script>This is an integration example, not a complete production UI. Add your own visual design, pagination, accessible status announcements, rate-limit handling, and sign-in link where appropriate. The example uses textContent for API-provided strings to avoid interpreting customer content as HTML.
Rate limits, origins, and errors
The current API limit is 120 requests per minute per API key. A request over the limit returns 429. Invalid or revoked keys return 401; inactive projects and disabled anonymous actions return 403; unknown feedback IDs return 404; invalid payloads return 400.
If an allowed-domain list exists, browser requests must have an Origin whose host matches one of the project's entries. Add your production host and local development host under Settings → Security. Requests without an Origin header, such as server-to-server requests, are not checked against that website allowlist.
All public API routes support cross-origin browser requests. Handle failed responses in the UI and show a useful retry/error message rather than treating a failed request as a successful submission.
What this API does not do
The public API is for a customer-facing feedback experience. It does not provide dashboard-user authentication or project-member management. Feedback status edits, project settings, API key management, and dashboard actions require an authenticated project member through the KyroFeedback dashboard. Do not build those administrative operations using a publishable key.