Adapter

A FeedbackAdapter is how BYO mode talks to your store. Helpers: createLocalAdapter (demo), createByoAdapter (wrap callbacks), createHostedAdapter (used internally when you pass boardId + publishableKey).

Resolve order

  1. If adapter is passed, use it (wins over Hosted props).
  2. Else if boardId and publishableKey are set, use Hosted.
  3. Else fall back to createLocalAdapter.

Modes and pricing: Modes.

Interface

type FeedbackAdapter = {
  listPosts(input: {
    boardId: string;
    status?: FeedbackStatus;
    cursor?: string;
    q?: string;
    includeMerged?: boolean;
    authorId?: string;
    sort?: "votes" | "recent";
    votedByViewer?: boolean;
    postId?: string;
  }): Promise<{ posts: FeedbackPost[]; nextCursor: string | null }>;
  createPost(input: {
    boardId: string;
    title: string;
    body?: string;
    author: FeedbackAuthor;
    authorToken?: string;
    idempotencyKey?: string;
    imageUrl?: string;
  }): Promise<FeedbackPost>;
  vote(input: {
    postId: string;
    author: FeedbackAuthor;
    authorToken?: string;
  }): Promise<void>;
  unvote(input: {
    postId: string;
    author: FeedbackAuthor;
    authorToken?: string;
  }): Promise<void>;
  uploadImage?(input: {
    boardId: string;
    file: File;
    author: FeedbackAuthor;
    authorToken?: string;
  }): Promise<{ url: string }>;
  updatePost?(input: {
    postId: string;
    title: string;
    body?: string;
    imageUrl?: string | null;
    author: FeedbackAuthor;
    authorToken?: string;
  }): Promise<FeedbackPost>;
  withdrawPost?(input: {
    postId: string;
    author: FeedbackAuthor;
    authorToken?: string;
  }): Promise<FeedbackPost>;
};

FeedbackStatus is open | planned | in_progress | shipped | declined. Optional authorToken is a host-signed assertion minted with POST /api/v1/author-tokens (secret key). Required when the board sets requireAuthorToken. See Identity.

Optional q on listPosts is how similar-request search looks past the loaded page. After 4 characters in the compose title, Hosted and BYO debounce listPosts({ q }). Honor q in BYO so duplicates on later pages can still surface. Local mode falls back to in-memory matching. Up to 3 clickable results jump to the post and focus Vote.

Optional includeMerged on listPosts is how the embed keeps a merged duplicate on the board. The widget sets it on the board list. When it is true, return archived posts whose mergedIntoPostId is set, and still omit other archived posts. Callers that omit the flag keep the archived-hidden list. The embed renders those rows as non-votable Merged into … pointers that focus the canonical post. Do not accept votes on a merged row, and do not clear archivedAt on the source.

Optional authorId is how My requests lists only that author's posts. Optional sort (votes or recent) is how Sort pages before cursor. Optional votedByViewer with viewerId is how Voted lists only that viewer's votes; with no viewer id, return an empty list. Optional postId returns that one live or merged-stub post so a permalink can pin a request that is not on the first page. When postId is set, ignore status, q, cursor, authorId, sort, and votedByViewer. Do not return a plain archived row.

The embed lists 50 posts per page. When listPosts returns nextCursor, it shows Load more. Honor optional cursor on BYO list routes so later pages can load. Vote and submit refresh the first page and keep already-loaded extras.

Optional screenshot: one JPEG, PNG, or WebP up to 4 MB. Persist imageUrl on createPost. Optional uploadImage if you host the file and return { url }. If omitted, the embed sends a data URL. Hosted hides Attach image; image upload is not available yet.

Optional updatePost lets the author correct title and body while status is open. Return the updated post. Omit updatePost and the embed hides Edit. Reject a different author.id, and reject the edit once status leaves open. The same authorToken rules as create apply. Do not change status in this method. Optional imageUrl: omit it and the screenshot stays; null or blank clears it; a jpeg, png, or webp data URL, or an http(s) URL, replaces it (data URLs, 4 MB). The embed shows Remove image and Replace image in that edit form only for the author of an open request. Hosted ignores imageUrl on update.

Optional withdrawPost archives the author's own open request by setting archivedAt. Return that post. Omit it and the embed hides Withdraw. Reject a different author, a non-open status, and an archived or merged post. Do not hard-delete the row. Default lists hide it. Hosted calls POST /api/v1/posts/:id with { "action": "withdraw" }. The inbox can still restore.

Throw on HTTP errors. A returned body with no posts array, or a write result with no post id, is a failure, so return res.json() on an error response cannot crash the list or confirm a post that was not saved.

BYO example

<Feedback
  boardId="brd_..."
  user={currentUser}
  adapter={{
    listPosts: async ({ boardId, status, cursor, q, includeMerged, authorId, postId, sort, votedByViewer }) => {
      const params = new URLSearchParams({ boardId });
      if (status) params.set("status", status);
      if (cursor) params.set("cursor", cursor);
      if (q) params.set("q", q);
      if (includeMerged) params.set("includeMerged", "1");
      if (authorId) params.set("authorId", authorId);
      if (sort) params.set("sort", sort);
      if (votedByViewer) params.set("votedByViewer", "1");
      if (postId) params.set("postId", postId);
      const res = await fetch(`/api/feedback?${params}`);
      const json = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(json.error ?? `Failed to load feedback (${res.status}).`);
      return json;
    },
    createPost: async (input) => {
      const res = await fetch("/api/feedback", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(input),
      });
      const json = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(json.error ?? `Could not create post (${res.status}).`);
      return json;
    },
    vote: async ({ postId, author }) => {
      const res = await fetch(`/api/feedback/${postId}/vote`, {
        method: "POST",
        body: JSON.stringify({ author }),
      });
      if (!res.ok) {
        const json = await res.json().catch(() => ({}));
        throw new Error(json.error ?? `Vote failed (${res.status}).`);
      }
    },
    unvote: async ({ postId, author }) => {
      const res = await fetch(`/api/feedback/${postId}/unvote`, {
        method: "POST",
        body: JSON.stringify({ author }),
      });
      if (!res.ok) {
        const json = await res.json().catch(() => ({}));
        throw new Error(json.error ?? `Vote failed (${res.status}).`);
      }
    },
    updatePost: async ({ postId, title, body, imageUrl, author }) => {
      const res = await fetch(`/api/feedback/${postId}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title, body, imageUrl, author }),
      });
      const json = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(json.error ?? `Could not update request (${res.status}).`);
      return json;
    },
    withdrawPost: async ({ postId, author }) => {
      const res = await fetch(`/api/feedback/${postId}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ action: "withdraw", author }),
      });
      const json = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(json.error ?? `Could not withdraw request (${res.status}).`);
      return json;
    },
  }}
/>

Routes are illustrative. Enforce authz on your API using the signed-in session, do not trust the browser alone. Enforce one vote per author.id per post if you want honest counts. Forward optional q on your list route so similar requests can search beyond the page currently on screen. updatePost in the sample is optional.

Limits and content

  • Title max 200 characters; body max 5000 (plain text). Optional image jpeg/png/webp, 4 MB.
  • Return FeedbackPost shapes the widget expects (id, boardId, title, body, status, voteCount, author fields, timestamps).
  • BYO does not emit Bootstrapware feedback.post_* webhooks, emit your own from your API if needed.

Walkthrough: Own your feedback API.

Related: Modes · Identity · Limits · Quickstart · Postgres