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.commentson its list responses (instoremode: the store implementsaddComment), and the feedback'spermissionsdo not refuse replies. Against a server that predates threads — nocapabilities, nocomments— the widget shows no thread and nothing breaks. - Who replies: the reviewer, resolved like a feedback's author — your
identityoption, 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 asclient. - 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
onCommentAddedand thecomment:addedevent (payload: the stored comment,CommentResponse); a failure goes toonErrorandfeedback:error, like any API call. - Reading the answer needs the list: with an
apiKey, add"GET"topublicEndpoints— 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'spermissionsrefuse replies; a reply offers no delete when the endpoint advertises no deletion, the feedback'spermissionsrefuse it, or the inbox isreadOnly.- Replies ask for the
teamrole, which the server keeps only for a caller your access policy vouches for — theapiKey, orcanCommentAsTeamunderaccess(see who speaks as the team). Any other reply is stored asclient. - Nothing is optimistic: a reply shows once stored, and a failure keeps the draft. Deleting a reply asks first; like every
DELETE, it needs theapiKeyor your access policy's consent. - Headless:
useSitepingInboxreturnscanComment,canDeleteComment,addComment(id, body, clientId?)anddeleteComment(id, commentId)— see the hook. A custom source opts in withaddCommentandremoveComment.
What a comment holds
| Field | Type | Notes |
|---|---|---|
id | string | Assigned by the store |
feedbackId | string | The feedback whose thread holds it |
body | string | Trimmed, 1 to 5000 characters |
authorName | string | 1 to 200 characters |
authorEmail | string | A 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 |
createdAt | ISO string | When 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
clientIdthe client generates. Posting the sameclientIdagain 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
updatedAtalone.updatedAttracks the feedback's own fields (its status); the newest comment'screatedAttells 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
| Status | When |
|---|---|
400 { errors } | The body fails validation (empty body text, invalid email, missing clientId…) |
401 / 403 | Missing 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
apiKeypolicy, the request must carryAuthorization: Bearer <apiKey>. The publicPOST, a wrong key, and a handler without anapiKeyall getclient. A widget configured withapiKeysends that key from every visitor's browser, so its visitors could post as the team: keepapiKeyout of public widgets. - Under
access,canCommentAsTeam(principal)decides. Without it, yourcanReadAuthorEmailanswer 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 commentclient: 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,publicEndpointsdefaults to["POST", "OPTIONS"]:GETneeds the key, so a public widget can post a comment but not read the answer. Add"GET"topublicEndpointsto let reviewers read their threads — emails stay redacted. - Rate limiting. The public
POSTtakes comments too: anyone who knows a feedback id and your project name can add one. Rate-limitPOSTin your framework or reverse proxy; the 100-comment cap bounds how large one thread can grow, not how fast. - Notifications. Webhooks and
onCreatedfire for new feedbacks only, not for comments.
Enable threads on your store
| Store | What to do |
|---|---|
| Prisma | Run 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 |
| Drizzle | Export sitepingComments from your schema next to the other tables, then generate and apply the migration with drizzle-kit |
| Memory, localStorage | Nothing — both keep threads. localStorage data saved by an earlier version reads with empty threads |
| Your own | Implement the optional addComment and deleteComment — createCollectionStore provides both |