If you want the board to look exactly like the rest of your product, skip the embed and call the Feedback API directly. It’s the same API our board uses, so you get every feature.
The basics#
The complete endpoint list, with every field and error, is in the Feedback API reference. This page walks through building a board with it.
Build a board in four steps#
Decide on the visitor id
Every write needs an
x-visitor-idheader matching^[a-zA-Z0-9_-]{12,64}$. It’s how Relay knows who voted.Derive it from your user id. The embed uses exactly this rule, so someone who votes in the embed and in your own UI counts as one person.
jsfunction visitorIdFor(userId) { const safe = String(userId).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 62); return "u_" + safe.padEnd(10, "0"); } visitorIdFor("usr_123"); // "u_usr_123000"Generate a random id once and keep it in the browser:
jsfunction anonymousVisitorId() { let id = localStorage.getItem("feedback_vid"); if (!id) { id = "v_" + crypto.randomUUID().replace(/-/g, ""); localStorage.setItem("feedback_vid", id); } return id; }Write a tiny client
relay-feedback.jsconst RELAY = "https://support.notaislop.xyz/api/feedback"; export async function relay(path, { method = "GET", body, visitorId } = {}) { const res = await fetch(RELAY + path, { method, headers: { "content-type": "application/json", "x-relay-key": "wk_XXXXXXXXXXXXXXXXXXXXXXXX", ...(visitorId && { "x-visitor-id": visitorId }), }, body: body && JSON.stringify(body), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); return data; }List, post, vote and comment
jsconst me = visitorIdFor(currentUser.id); // Ideas, most-voted first, with this user's votes filled in const { posts, counts } = await relay("?sort=top", { visitorId: me }); // Suggest an idea (the author's upvote is added automatically) const { post } = await relay("", { method: "POST", visitorId: me, body: { title: "Export to CSV", description: "For our finance team.", name: currentUser.name }, }); // Toggle an upvote await relay(`/${post.id}/vote`, { method: "POST", visitorId: me, body: { voted: !post.voted } }); // One idea with its comments const { post: detail, comments } = await relay(`/${post.id}`, { visitorId: me }); // Comment await relay(`/${post.id}/comments`, { method: "POST", visitorId: me, body: { body: "Yes please!", name: currentUser.name } });Render it your way
Use
countsfor filter tabs,votedfor the upvote state, andstatusfor badges. Style comments withauthor: "agent"as official team replies.
Example: an upvote button in React#
Update the count immediately, then correct it with the server’s answer, and roll back on failure.
import { relay } from "./relay-feedback";
export function Upvote({ post, visitorId, onChange }) {
async function toggle() {
const voted = !post.voted;
onChange({ ...post, voted, votes: post.votes + (voted ? 1 : -1) }); // optimistic
try {
const { post: saved } = await relay(`/${post.id}/vote`, {
method: "POST",
visitorId,
body: { voted },
});
onChange(saved);
} catch {
onChange(post); // roll back
}
}
return (
<button onClick={toggle} aria-pressed={post.voted} className="upvote">
▲ {post.votes}
</button>
);
}Status labels#
Statuses are machine values; show your own labels. Ours are:
const STATUS_LABEL = {
open: "Under review",
planned: "Planned",
in_progress: "In progress",
done: "Complete",
closed: "Closed",
};Server-side use#
No secret is needed, so you can also call the API from your backend, for example to render the board server-side or mirror ideas into your own database. Pass the visitor id of the user you’re acting for.
Good practice#
Debounce search#
Wait about 250ms after the last keystroke before calling ?q=.
Respect field limits#
Titles are capped at 120 characters, descriptions at 4000, comments at 2000 and names at 80. Longer input is trimmed by the server, so set maxLength on your inputs to avoid surprises.
Show errors from the API#
Errors come back as { "error": "…" } with a readable message such as “Title is required”. They’re safe to show to users as-is.
Rate limiting