RelayDocs

Feedback board

Build your own UI

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#

Base URLhttps://support.notaislop.xyz/api/feedback
WorkspaceYour public key, as ?key=wk_XXXXXXXXXXXXXXXXXXXXXXXX or an x-relay-key header. Shown under Inbox → Settings → Install.
FormatJSON in, JSON out. Send content-type: application/json on POST.
Cross-originCORS is open to any origin for GET, POST and OPTIONS. No cookies are involved.
AuthenticationNone. Each request says who is acting with the x-visitor-id header.

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#

  1. Decide on the visitor id

    Every write needs an x-visitor-id header 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.

    js
    function 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"
  2. Write a tiny client

    relay-feedback.js
    const 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;
    }
  3. List, post, vote and comment

    js
    const 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 } });
  4. Render it your way

    Use counts for filter tabs, voted for the upvote state, and status for badges. Style comments with author: "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.

Upvote.jsx
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:

js
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#

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

New posts, comments and votes are rate-limited per IP address. If you call the API from your backend, every request comes from your server’s IP and shares one limit, so send writes from the browser where you can.