Discussion threads

Comments on a feedback, stored next to it and served by the same endpoint — with the team role and reviewer emails guarded by your access policy.

A feedback carries a thread of comments: the reviewer asks "16 or 24 px?", the team answers, and the conversation stays next to the report instead of moving to email. Threads are kept by your store and served by @siteping/server on the endpoint you already mounted — no new route.

The widget shows the thread to the reviewer and lets them reply; the dashboard shows it to your team, who reply and delete there. The rest of this page covers storage and the HTTP API.

In the widget

The thread reads on from the message in the panel's feedback detail view, oldest first, with a composer under it. The team's replies carry a Team badge.

  • The composer shows only when replies can be kept: the endpoint advertises capabilities.comments on its list responses (in store mode: the store implements addComment), and the feedback's permissions do not refuse replies. Against a server that predates threads — no capabilities, no comments — the widget shows no thread and nothing breaks.
  • Who replies: the reviewer, resolved like a feedback's author — your identity option, else the one saved in this browser, else the identity prompt. Dismissing the prompt sends nothing and reports nothing; the draft stays. A widget reply is always stored as client.
  • Sending: the button or Ctrl/⌘+Enter. Network failures and 5xx are retried like a feedback's, under the same clientId, so a retry never duplicates the reply. A reply is never queued for a later page load: if it still fails, the draft stays and the thread says it wasn't sent.
  • Your code hears about it through onCommentAdded and the comment:added event (payload: the stored comment, CommentResponse); a failure goes to onError and feedback:error, like any API call.
  • Reading the answer needs the list: with an apiKey, add "GET" to publicEndpoints — see Public endpoints.

In the dashboard

The drawer shows the opened feedback's thread, and lets your team reply and delete once you tell the inbox who is writing:

<SitepingInbox
  projects="my-project"
  endpoint="/api/siteping"
  apiKey={KEY}
  author={{ name: user.name, email: user.email }}
/>
  • author ({ name, email? }) is who replies from this inbox. Without it, threads are read-only — and hidden when empty. So are they when the endpoint advertises no comments, or when the feedback's permissions refuse replies; a reply offers no delete when the endpoint advertises no deletion, the feedback's permissions refuse it, or the inbox is readOnly.
  • Replies ask for the team role, which the server keeps only for a caller your access policy vouches for — the apiKey, or canCommentAsTeam under access (see who speaks as the team). Any other reply is stored as client.
  • Nothing is optimistic: a reply shows once stored, and a failure keeps the draft. Deleting a reply asks first; like every DELETE, it needs the apiKey or your access policy's consent.
  • Headless: useSitepingInbox returns canComment, canDeleteComment, addComment(id, body, clientId?) and deleteComment(id, commentId) — see the hook. A custom source opts in with addComment and removeComment.

What a comment holds

FieldTypeNotes
idstringAssigned by the store
feedbackIdstringThe feedback whose thread holds it
bodystringTrimmed, 1 to 5000 characters
authorNamestring1 to 200 characters
authorEmailstringA valid address, or "" when the author has none on file (a dashboard user). Redacted like the feedback's — see emails
authorRole"client" | "team"client for the reviewer on the site, team for the project side — see who speaks as the team
createdAtISO stringWhen the store saved it

Every feedback in a response carries comments, oldest first: [] when the thread is empty, or when the store keeps no comments at all.

  • Idempotent posts. A comment also carries a clientId the client generates. Posting the same clientId again returns the stored comment instead of adding it twice, so a retried request never duplicates a reply. Like the feedback's, it is never sent back.
  • 100 comments per thread (MAX_COMMENTS_PER_FEEDBACK). Lists embed whole threads, which are not paginated, so the cap bounds what one feedback weighs.
  • A comment leaves the feedback's updatedAt alone. updatedAt tracks the feedback's own fields (its status); the newest comment's createdAt tells when the thread last moved.
  • Deleting a feedback deletes its thread.

HTTP API

Comments share the one endpoint: a POST body with a feedbackId is a comment, a DELETE body with a commentId deletes one. A feedback's own bodies never carry those keys.

Post a comment

POST /api/siteping
Content-Type: application/json

{
  "projectName": "my-site",
  "feedbackId": "cm3f8x…",
  "body": "Is it 16 or 24 px?",
  "authorName": "Alice",
  "authorEmail": "alice@client.example",
  "clientId": "4f0c2b8e-4c1d-4f7a-9d4e-6b1f2a3c5d7e"
}

It answers 201 with the comment. authorRole is optional and defaults to client. clientId is required — [a-zA-Z0-9_-], up to 200 characters: generate one per comment (crypto.randomUUID()) and resend the same one on a retry.

Delete a comment

DELETE /api/siteping
Content-Type: application/json

{ "projectName": "my-site", "feedbackId": "cm3f8x…", "commentId": "cm4a1k…" }

It answers 200 { "deleted": true }. Like every DELETE, it needs the apiKey — or whatever your access policy requires.

Read threads

The GET list is unchanged, with two additions: each feedback's comments, and a capabilities object that says whether the store can take comments (comments) and delete them (deleteComments) — so a client can hide its reply box, or its delete buttons, up front instead of meeting a 501:

{
  "feedbacks": [{ "id": "cm3f8x…", "message": "…", "comments": [] }],
  "total": 1,
  "capabilities": { "comments": true, "deleteComments": true }
}

Errors

StatusWhen
400 { errors }The body fails validation (empty body text, invalid email, missing clientId…)
401 / 403Missing credentials, or refused by authorize or a CSRF check
404 { error: "Feedback not found" }Unknown feedback, or one of another project
404 { error: "Comment not found" }The comment is not on that feedback's thread
409 { error }The thread already holds 100 comments, or the clientId is already used on another feedback
501 { error: "Comments are not supported by this store" }The store keeps no comments — see enabling threads

Who speaks as the team

The widget posts from anonymous browsers, so the authorRole a request sends is a claim. The handler keeps team only when your access policy vouches for the caller, and stamps every other comment client:

  • Under the apiKey policy, the request must carry Authorization: Bearer <apiKey>. The public POST, a wrong key, and a handler without an apiKey all get client. A widget configured with apiKey sends that key from every visitor's browser, so its visitors could post as the team: keep apiKey out of public widgets.
  • Under access, canCommentAsTeam(principal) decides. Without it, your canReadAuthorEmail answer does — whoever may read reviewer emails is on the project side — so a policy that already tells visitors apart needs nothing more. A policy with neither callback stamps every comment client: posting as the team lets a caller speak for your agency in the thread, so it is never granted by default.

authorize sees two more actions: createComment and deleteComment, with the feedbackId — and the commentId for a delete. On a dryRun, which fills in the feedback's permissions, deleteComment comes without a commentId and its answer covers the whole thread: guard a per-author rule with if (!commentId) return false, which hides the delete buttons while the real DELETE still goes through your rule.

createSitepingHandler({
  store,
  access: {
    authenticate: (request) => getSessionUser(request),
    authorize: ({ principal, action }) =>
      action === "deleteComment" ? principal.isStaff : true,
    canReadAuthorEmail: (principal) => principal.isStaff,
    // Optional here: it would default to canReadAuthorEmail.
    canCommentAsTeam: (principal) => principal.isStaff,
  },
});

Reviewer emails

A comment's authorEmail is redacted exactly like the feedback's: blank unless the requester may read it — the Bearer apiKey, or canReadAuthorEmail under access. The answer to a comment POST echoes the new comment's own email to its author, like a feedback POST does; a replayed feedback POST echoes the submitter's email only, never the thread's.

Public endpoints and abuse

  • Reading replies from the widget. With an apiKey, publicEndpoints defaults to ["POST", "OPTIONS"]: GET needs the key, so a public widget can post a comment but not read the answer. Add "GET" to publicEndpoints to let reviewers read their threads — emails stay redacted.
  • Rate limiting. The public POST takes comments too: anyone who knows a feedback id and your project name can add one. Rate-limit POST in your framework or reverse proxy; the 100-comment cap bounds how large one thread can grow, not how fast.
  • Notifications. Webhooks and onCreated fire for new feedbacks only, not for comments.

Enable threads on your store

StoreWhat to do
PrismaRun npx @siteping/cli sync to add the SitepingComment model, then npx prisma db push (or npx prisma migrate dev). Until the client is generated with it, threads read empty and comment writes answer 501
DrizzleExport sitepingComments from your schema next to the other tables, then generate and apply the migration with drizzle-kit
Memory, localStorageNothing — both keep threads. localStorage data saved by an earlier version reads with empty threads
Your ownImplement the optional addComment and deleteComment — createCollectionStore provides both
Edit on GitHub

On this page