Backend API guide
REST API reference for self-hosting fivepixels. v2 splits feedback (comments) and replies into separate resources — lists stay light, threads load on click.
Getting started
This is a self-host reference (v2).
The library cares about handler return shapes, not a specific REST stack.
You can rename paths (/comments, etc.) — just keep the JSON shapes.
With fivepixels hosting (coming soon), you do not need to build this API.
At a glance
Wire handlers on <FivePixels />. Each handler calls your API below.
Terminology
| REST name | Library type | In plain terms |
|---|---|---|
| Comment | ReportFeedback | One feedback item |
| Case | ReportCase | An issue/item inside feedback (the body) |
| Reply | ReportReply | A thread reply |
| Member | ReportAuthor | Reviewer/assignee dropdown options |
Handler ↔ API
| Priority | Handler | Suggested REST | Purpose | Go to |
|---|---|---|---|---|
| Required | onList | GET /comments?pathname=... | Feedback summaries for current page | |
| Required | onCreate | POST /comments | Create feedback | |
| Required | onUpdate | PATCH /comments/{id} | Edit cases, status, integrations | |
| v2 recommended | onListReplies | GET /comments/{id}/replies | Thread — on marker click | |
| v2 recommended | onCreateReply | POST /comments/{id}/replies | Add one reply | |
| Optional | onDelete | DELETE /comments/{id} | Delete feedback | |
| Optional | onListAll | GET /comments?cursor=&limit=100 | All pages, summarized | |
| Optional | team.reviewers | GET /members | Reviewer dropdown list |
Pass onList·onCreate·onUpdate together, or omit all three (localStorage is used).
Without v2 handlers (onListReplies·onCreateReply), the library falls back to v1 (PATCH entire replies array).
How v2 works
- No realtime connection — API calls only on user action or navigation
- Filtering is client-side — no search/status filter API needed
- Body is cases[] — no top-level message field
- Lists stay light — reply_count + latest_reply instead of replies[]
- Threads on click — marker/list click → onListReplies
- One reply at a time — POST /replies (not PATCH whole array)
- Refresh after changes — re-fetch list or thread after create/update/delete
v1 vs v2
| v1 (legacy) | v2 (recommended) | |
|---|---|---|
| List | Full replies[] included | Summary only (reply_count, latest_reply) |
| Thread | Already in list | GET /replies on click |
| Add reply | PATCH + replace replies array | POST /replies one item |
| DB | Single table + replies JSONB | comments + replies tables |
Project scope
<FivePixels project={{ id, env, version }} /> tells the library which app and environment. Store data scoped by projectId and environment.
| Frontend prop | Storage field | Description |
|---|---|---|
| project.id | (managed in API) | App/project ID. Default my-app |
| project.env | environment | stage, production, etc. |
| project.version | app_version | Deployed version |
GET /api/projects/{projectId}/comments?pathname=/pricing&environment=stageCreate payloads may include environment and app_version. Store them as sent.
Data model
Feedback body lives in Case array (`cases[]`). Multiple issues in one feedback = multiple Cases. Replies are a separate resource.
Case — one piece of feedback body
{
"id": "case-001",
"text": "가격 카드가 모바일에서 잘림",
"status": "open",
"assignee_name": null,
"created_at": "2026-06-07T09:00:00.000Z",
"updated_at": "2026-06-07T09:00:00.000Z"
}- text — the actual feedback (not whitespace-only)
- status — open or resolved
- assignee_name — assignee name (null if none)
- When all Cases are resolved, the frontend syncs feedback status to resolved
Comment — full feedback
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"pathname": "/pricing",
"report_id": "price-card",
"report_type": "item",
"target_selector": "[data-report-id=\"price-card\"]",
"cases": [
{
"id": "case-001",
"text": "가격 카드가 모바일에서 잘림",
"status": "open",
"assignee_name": null,
"created_at": "2026-06-07T09:00:00.000Z",
"updated_at": "2026-06-07T09:00:00.000Z"
}
],
"status": "open",
"field_values": { "isBug": true },
"position": {
"target": { "x": 0.25, "y": 0.75 },
"viewport": { "x": 0.42, "y": 0.18, "width": 1440, "height": 900 },
"scrollY": 120,
"anchor": null
},
"created_at": "2026-06-07T09:00:00.000Z",
"environment": "stage",
"app_version": "1.2.0",
"author_id": "user-1",
"author_name": "김아영",
"integrations": {
"github": {
"issue_number": 42,
"issue_url": "https://github.com/org/repo/issues/42",
"issued_at": "2026-06-07T10:00:00.000Z"
}
}
}field_values holds custom field values from <FivePixels fields={[...]} />. Separate from cases[].text.
CommentSummary — list API response
Returned by onList / onListAll. No `replies[]`. Only reply_count and latest_reply.
{
"id": "cmt-001",
"pathname": "/pricing",
"report_id": "price-card",
"report_type": "item",
"cases": [
{
"id": "case-001",
"text": "가격 카드가 모바일에서 잘림",
"status": "open",
"assignee_name": null,
"created_at": "2026-06-07T09:00:00.000Z",
"updated_at": "2026-06-07T09:00:00.000Z"
}
],
"status": "open",
"field_values": { "isBug": true },
"position": { "...": "..." },
"created_at": "2026-06-07T09:00:00.000Z",
"reply_count": 3,
"latest_reply": {
"id": "rpl-003",
"message": "iOS Safari에서 재현됨",
"created_at": "2026-06-07T11:30:00.000Z",
"status": "found_error",
"case_ids": ["case-001"],
"author_type": "manager",
"author_name": "PM"
}
}Zero replies → reply_count: 0, latest_reply: null.
Comment key fields
| Field | Required | Description |
|---|---|---|
| pathname | ✅ | Page path (e.g. /pricing) |
| report_id | ✅ | DOM data-report-id |
| report_type | ✅ | group or item |
| cases | ✅ | Case array (1 or more) |
| status | ✅ | open | git_issued | resolved | archived |
| field_values | ✅ | Custom field values |
| position | ✅ | Marker position (see below) |
| reply_count | list only | Number of replies |
| latest_reply | list only | Latest reply summary (includes case_ids) |
| target_selector | CSS selector when no report_id | |
| integrations | GitHub Issue metadata |
position — marker placement
Keys are camelCase (scrollY, reportId, etc.). One JSON column is fine.
{
"target": { "x": 0.25, "y": 0.75 },
"viewport": { "x": 0.42, "y": 0.18, "width": 1440, "height": 900 },
"scrollY": 120,
"anchor": null
}anchor example (inside modal, etc.)
{
"reportId": "modal-demo",
"reportType": "group",
"x": 0.5,
"y": 0.5
}Reply
{
"id": "rpl-001",
"comment_id": "cmt-001",
"message": "재현 확인했습니다",
"created_at": "2026-06-07T10:00:00.000Z",
"status": "suggested",
"case_ids": ["case-001"],
"parent_reply_id": null,
"author_type": "manager",
"author_name": "리뷰어"
}| status | Meaning |
|---|---|
| suggested | Suggestion or answer |
| additional_question | Follow-up question |
| found_error | Error found |
| recheck_requested | Recheck requested |
| resolved | Resolved |
| author_type | Meaning |
|---|---|
| user | Feedback author |
| manager | Reviewer/PM |
| system | System message (GitHub Issue, etc.) |
API endpoints
Paths are examples. Adapt to your conventions, but keep request/response JSON shapes.
GET — current page list (onList)
DB: comments SELECT + replies aggregation (reply_count, latest_reply)
Called on page load, pathname change, and after create/update/delete.
GET /api/projects/{projectId}/comments?pathname=/pricing&environment=stage[
{
"id": "cmt-001",
"pathname": "/pricing",
"report_id": "price-card",
"cases": [{ "...": "..." }],
"reply_count": 3,
"latest_reply": { "...": "..." },
"...": "..."
}
]- Return items with exact pathname match
- Sort by created_at descending (recommended)
- Include reply_count and latest_reply — no replies[]
List aggregation SQL (PostgreSQL)
SELECT
c.*,
COUNT(r.id) AS reply_count,
(
SELECT row_to_json(lr)
FROM (
SELECT id, message, created_at, status, case_ids, author_type, author_name
FROM replies
WHERE comment_id = c.id
ORDER BY created_at DESC
LIMIT 1
) lr
) AS latest_reply
FROM comments c
LEFT JOIN replies r ON r.comment_id = c.id
WHERE c.project_id = $1 AND c.environment = $2 AND c.pathname = $3
GROUP BY c.id
ORDER BY c.created_at DESC;GET — all pages (onListAll, optional)
DB: comments + replies aggregation (same as onList), no pathname filter + cursor pagination
GET /api/projects/{projectId}/comments?cursor=abc&limit=100&environment=stage{
"items": [
{ "...CommentSummary" }
],
"nextCursor": "def"
}Omit or null nextCursor on the last page. Skip this API if onListAll is not wired.
GET — thread (onListReplies)
DB: replies WHERE comment_id = :id, ORDER BY created_at ASC
Called when opening a marker or list thread. Return 404 if comment not found.
GET /api/projects/{projectId}/comments/cmt-001/replies[
{
"id": "rpl-001",
"comment_id": "cmt-001",
"message": "재현 확인했습니다",
"created_at": "2026-06-07T10:00:00.000Z",
"status": "suggested",
"case_ids": ["case-001"],
"parent_reply_id": null,
"author_type": "manager",
"author_name": "리뷰어"
}
]- That comment's replies only, created_at ascending
- Each Reply includes case_ids
POST — create feedback (onCreate)
DB: comments INSERT (includes cases JSONB; server generates id, created_at)
POST /api/projects/{projectId}/comments
Content-Type: application/json{
"pathname": "/pricing",
"report_id": "price-card",
"report_type": "item",
"target_selector": "[data-report-id=\"price-card\"]",
"cases": [
{
"id": "case-001",
"text": "가격 카드가 모바일에서 잘림",
"status": "open",
"assignee_name": null,
"created_at": "2026-06-07T09:00:00.000Z",
"updated_at": "2026-06-07T09:00:00.000Z"
}
],
"status": "open",
"field_values": { "isBug": true },
"position": {
"target": { "x": 0.5, "y": 0.5 },
"viewport": { "x": 0.42, "y": 0.18, "width": 1440, "height": 900 },
"scrollY": 120,
"anchor": null
},
"environment": "stage",
"app_version": "1.2.0",
"author_id": "user-1",
"author_name": "김아영"
}{
"id": "cmt-001",
"pathname": "/pricing",
"cases": [{ "...": "..." }],
"status": "open",
"reply_count": 0,
"latest_reply": null,
"...": "..."
}- Server generates comment id; client sends cases[].id
- At least one case; each text must not be whitespace-only
- Response 201: reply_count: 0, latest_reply: null
POST — create reply (onCreateReply)
DB: replies INSERT (includes case_ids JSONB)
POST /api/projects/{projectId}/comments/cmt-001/replies
Content-Type: application/json{
"message": "iOS Safari에서 재현됨",
"status": "found_error",
"case_ids": ["case-001"],
"parent_reply_id": null,
"author_type": "manager",
"author_name": "PM"
}- INSERT into replies table
- case_ids must reference that comment's cases[].id
- GitHub Issue notifications use author_type: system on the same API
PATCH — update feedback (onUpdate)
DB: comments UPDATE (partial merge: cases, status, field_values, integrations, etc.)
Send only changed fields (partial update). v2 does not accept a replies field.
PATCH /api/projects/{projectId}/comments/cmt-001
Content-Type: application/json{
"cases": [
{
"id": "case-001",
"text": "수정된 케이스 내용",
"status": "resolved",
"assignee_name": "PM",
"created_at": "2026-06-07T09:00:00.000Z",
"updated_at": "2026-06-07T12:00:00.000Z"
}
],
"status": "resolved"
}Custom field update example
{
"field_values": { "isBug": true, "isImportant": false }
}GitHub Issue example
{
"status": "git_issued",
"integrations": {
"github": {
"issue_number": 42,
"issue_url": "https://github.com/org/repo/issues/42",
"issued_at": "2026-06-07T10:00:00.000Z"
}
}
}| Allowed PATCH fields | Description |
|---|---|
| cases | Replace entire case array |
| status | Feedback status |
| field_values | Custom fields |
| integrations | GitHub etc. |
| report_id / report_type | Change target element (rare) |
Do not PATCH position after create — there is no move-marker UI.
v1 fallback: without onCreateReply, the frontend may PATCH a replies array. v2 APIs can ignore it or return 400.
DELETE (onDelete, optional)
DB: comments DELETE (prefer CASCADE for replies)
DELETE /api/projects/{projectId}/comments/cmt-001Response 204 No Content. Prefer ON DELETE CASCADE for replies.
Team / reviewers API (optional)
Separate from feedback CRUD. fivepixels only accepts team.reviewers as a prop. Without an API, hardcode in frontend or fall back to free-text input.
Role split
| Data | Source | Stored? |
|---|---|---|
| team.reviewers | GET /members → prop | No (dropdown options) |
| team.user | Login session | No (current user) |
| cases[].assignee_name | Review actions | Yes, in comments.cases JSON |
reviewers = dropdown options. assignee_name = actual assignment. No FK needed — store as a string snapshot.
Member shape
{
"id": "1",
"name": "Alex, QA Assistant",
"publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
}- publicKey — only needed with requireReviewerKey
- Empty reviewers array → free-text input fallback
GET — reviewer list
DB: project_members (or team DB) SELECT — no FK to feedback tables needed
GET /api/projects/{projectId}/members?environment=stage[
{ "id": "1", "name": "Alex, QA Assistant" },
{ "id": "2", "name": "Sophia, QA" }
]Frontend wiring example
function AppWithFivePixels() {
const [reviewers, setReviewers] = useState<ReportAuthor[]>([]);
useEffect(() => {
fetch("/api/projects/my-app/members?environment=stage")
.then((r) => r.json())
.then(setReviewers);
}, []);
return (
<FivePixels
project={{ id: "my-app", env: "stage" }}
team={{
user: { id: "demo-user", name: "Alex" },
reviewers,
}}
onList={...}
onCreate={...}
onUpdate={...}
/>
);
}Errors · auth
Error responses
The library shows a generic failure message on HTTP errors. Response body format is up to your API standard.
| Code | Situation |
|---|---|
| 400 | Invalid JSON or missing required fields |
| 404 | Unknown comment or reply id |
| 409 | (optional) Concurrent edit conflict |
| 500 | Server error |
Auth signature (optional)
With requireReviewerKey, create/update/reply bodies may include auth.
{
"auth": {
"author_id": "reviewer-1",
"algorithm": "ECDSA-P256-SHA256",
"action": "feedback:create",
"signed_at": "2026-06-07T09:00:00.000Z",
"signature": "base64url..."
}
}| action | When |
|---|---|
| feedback:create | Feedback creation |
| feedback:update | Feedback edit |
| reply:create | Reply creation |
Skip auth verification if you do not use team keys.
Frontend wiring · call flow
When your backend is ready, wire handlers and team prop like this:
<FivePixels
project={{ id: "my-app", env: "stage", version: "1.2.0" }}
team={{
user: { id: "demo-user", name: "Alex" },
reviewers, // GET /members 결과
}}
onList={({ pathname }) =>
fetch(
`/api/projects/my-app/comments?pathname=${encodeURIComponent(pathname)}&environment=stage`,
).then((r) => r.json())
}
onListAll={({ cursor, limit }) => {
const params = new URLSearchParams({ limit: String(limit), environment: "stage" });
if (cursor) params.set("cursor", cursor);
return fetch(`/api/projects/my-app/comments?${params}`).then((r) => r.json());
}}
onListReplies={(commentId) =>
fetch(`/api/projects/my-app/comments/${commentId}/replies`).then((r) => r.json())
}
onCreate={(payload) =>
fetch("/api/projects/my-app/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).then((r) => r.json())
}
onCreateReply={(commentId, payload) =>
fetch(`/api/projects/my-app/comments/${commentId}/replies`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).then((r) => r.json())
}
onUpdate={(id, payload) =>
fetch(`/api/projects/my-app/comments/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).then((r) => r.json())
}
onDelete={(id) =>
fetch(`/api/projects/my-app/comments/${id}`, { method: "DELETE" }).then(() => undefined)
}
/>Call flow
[앱 시작]
→ GET /members → team.reviewers 주입
[페이지 진입]
→ GET /comments?pathname=...
→ 요약 목록 (cases + reply_count, replies[] 없음)
[마커 +N / hover]
→ onList 응답의 reply_count, latest_reply 사용
[스레드 열기]
→ GET /comments/{id}/replies
→ Reply[] (case_ids 포함)
[피드백 작성]
→ POST /comments (cases[] 포함)
→ GET /comments?pathname=... (재조회)
[답변 작성]
→ POST /comments/{id}/replies (case_ids 포함)
→ GET /comments/{id}/replies 또는 목록 재조회
[케이스 / 상태 수정]
→ PATCH /comments/{id}
→ GET /comments?pathname=... (재조회)
[삭제]
→ DELETE /comments/{id}
→ GET /comments?pathname=... (재조회)
[전체 목록 탭]
→ GET /comments?cursor=&limit=100DB · checklist
Recommended two-table schema
comments
id UUID PK
project_id VARCHAR
environment VARCHAR
pathname VARCHAR
report_id VARCHAR
report_type VARCHAR
target_selector VARCHAR NULL
cases JSONB -- Case[] 배열
status VARCHAR
field_values JSONB
position JSONB
app_version VARCHAR
author_id VARCHAR
author_name VARCHAR
integrations JSONB
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ
replies
id UUID PK
comment_id UUID FK → comments(id) ON DELETE CASCADE
message TEXT
status VARCHAR
case_ids JSONB -- string[] (케이스 ID 목록)
parent_reply_id UUID NULL
author_type VARCHAR
author_name VARCHAR
created_at TIMESTAMPTZSuggested indexes: (project_id, environment, pathname, created_at DESC), (comment_id, created_at ASC)
You do not need to build
- Keyword/status filter APIs
- Embedded replies array / PATCH overwrite (v2)
- Realtime push / WebSocket
- Individual position updates
- Top-level message field (legacy — use cases[])
Pre-launch checklist
- GET /comments — pathname filter, CommentSummary[] (no replies[], includes cases[])
- GET /comments/{id}/replies — Reply[] with case_ids, created_at ASC
- POST /comments — at least one case, server generates id/created_at
- POST /comments/{id}/replies — single reply INSERT with case_ids
- PATCH /comments/{id} — partial merge, no replies field
- position camelCase (scrollY, reportId), other fields snake_case
- (optional) DELETE, listAll + cursor, projectId/environment scope
- (optional, recommended) GET /members → team.reviewers