Widget API

The public endpoints under /w/* are what the SDK talks to. You normally never call them yourself (the <Comment /> component does), but they are plain JSON-over-HTTPS and documented here for completeness. Shapes below mirror the shared zod contracts the server validates with.

Authentication

Every request needs two things:

Write operations additionally require X-Commenter-Token: cmt_ct_..., the per-browser commenter token issued by /w/identify or /w/code.

A project member may also present X-Editor-Token: cmt_et_..., which grants editor capabilities on that project’s threads. The token is minted in project settings and reaches the widget through a one-time link (https://your-site/#cmt-editor=…); the widget stores it per project and strips it from the URL. It expires after 30 days and stops working the moment the person leaves the project. Both tokens may be sent together.

request headers
POST /w/identify HTTP/1.1
Origin: https://staging.example.com
Authorization: Bearer cmt_pk_yourkey...
Content-Type: application/json

Errors and rate limits

Errors are JSON with a stable code. Rate limits (per instance): 60 reads per minute per IP, 10 writes per minute per IP and per token, and 5 identify/code requests per hour per IP + email. 429 responses include Retry-After.

error shape
{
  "error": "Origin is not on the project allowlist",
  "code": "origin_not_allowed"
}
// code is one of:
// "bad_request" | "unauthorized" | "forbidden" | "not_found"
// | "origin_not_allowed" | "rate_limited" | "disabled"

Endpoints

EndpointTokenPurpose
POST /w/identifynoneName + email in, commenter token out.
POST /w/codenone6-digit code sign-in for claimed accounts on a new device.
GET /w/threads?path=optionalThreads (with messages) for a page path.
POST /w/threadsrequiredCreate a thread with its first message.
POST /w/threads/:id/messagesrequiredReply to a thread.
DELETE /w/messages/:idrequiredSoft-delete a message the token owns.
POST /w/threads/:id/resolverequiredResolve or reopen a thread you authored, or any thread as an editor.
DELETE /w/threads/:idrequiredDelete a thread you authored, or any thread as an editor.
GET /w/sessionoptionalReport whether the presented editor token is still valid.
POST /w/uploadsrequiredIssue a signed PUT URL for a screenshot.

POST /w/identify

Creates (or reuses) a commenter in the project and issues a browser token. The first-ever identify for an email also sends the confirmation email that doubles as the account claim link.

request
// POST /w/identify: request body
{
  "name": "Jane Doe",          // 1-100 chars
  "email": "jane@example.com"  // lowercased, max 320 chars
}
response
// 200 response
{
  "token": "cmt_ct_...",   // commenter token; empty string when requiresCode
  "commenterId": "…",
  "name": "Jane Doe",
  "email": "jane@example.com",
  "claimed": false,        // true when this email belongs to a verified account
  "requiresCode": false    // true: run the code sign-in instead (no token issued)
}

When the email belongs to a claimed (verified) account, requiresCode is true and no token is issued. A token for someone else’s verified email is never handed out. Switch to /w/code.

POST /w/code

New-device sign-in for claimed accounts. Codes are single-use, expire after 10 minutes, and allow at most 5 verification attempts.

request forms
// POST /w/code: two request forms
{ "email": "jane@example.com" }                    // 1: email a 6-digit code
{ "email": "jane@example.com", "code": "123456" }  // 2: verify → IdentifyResponse

GET /w/threads?path=

Returns open and resolved threads for a page path, messages included. Matching is by path only. See Anchoring for how query strings are handled. Pass X-Commenter-Token to get own flags on your messages.

thread shape
// Thread
{
  "id": "…",
  "pagePath": "/pricing",
  "pageUrl": "https://staging.example.com/pricing?plan=annual",
  "anchor": { /* anchor record, see below */ },
  "status": "open",              // "open" | "resolved"
  "screenshotUrl": null,         // short-lived signed URL or null
  "linearIssueUrl": null,
  "createdAt": "2026-08-20T12:00:00.000Z",
  "messages": [
    {
      "id": "…",
      "threadId": "…",
      "authorName": "Jane Doe",
      "fromEditor": false,
      "own": true,               // present on widget API responses only:
                                 // your X-Commenter-Token owns this message
      "body": "This button wraps on mobile",
      "createdAt": "2026-08-20T12:00:00.000Z",
      "deletedAt": null
    }
  ]
}
anchor record
// Anchor record (version 1)
{
  "version": 1,
  "dom": {                       // null when no usable element was found
    "selector": "#pricing-cta",  // id > data-testid > structural path
    "offsetX": 0.5,              // click offset as fractions of the element box
    "offsetY": 0.5,
    "fingerprint": "Start free trial"  // first ~80 chars of innerText
  },
  "page": { "x": 0.62, "y": 0.31 },    // fractions of full document size
  "context": {
    "url": "https://staging.example.com/pricing?plan=annual",
    "path": "/pricing",
    "viewport": { "width": 390, "height": 844 },
    "devicePixelRatio": 3,
    "scroll": { "x": 0, "y": 420 },
    "os": "iOS",                 // nullable
    "browser": "Safari",         // nullable
    "browserVersion": "26"       // nullable
  }
}

POST /w/threads

request
// POST /w/threads: request body
{
  "anchor": { /* anchor record */ },
  "body": "This button wraps on mobile",  // 1-10,000 chars
  "uploadId": "shots/…"                   // optional, from POST /w/uploads
}
// → 201 { "thread": Thread }

uploadId must be a key issued by POST /w/uploads for the same project; keys from other projects are rejected.

POST /w/threads/:id/messages

request
// POST /w/threads/:id/messages: request body
{ "body": "Fixed in the next deploy" }  // 1-10,000 chars
// → 201 { "message": Message }

DELETE /w/messages/:id

Soft-deletes a message the presenting commenter token owns (deletedAt is set; the thread keeps its shape). Deleting someone else’s message is rejected.

POST /w/threads/:id/resolve

Body { resolved: boolean }. Returns the updated thread. Permitted for the thread’s author and for project editors; anyone else gets 403.

DELETE /w/threads/:id

Deletes the thread and its screenshot. Same permission rule as resolve.

GET /w/session

Returns { editor, editorName }. Call it when holding an editor token to find out whether it is still valid. An expired or revoked token reports editor: false rather than failing the request.

Permission flags

Every thread has canResolve and canDelete, and every message has canDelete. The server works them out from the credentials on the request. Render controls from these rather than deriving permission on the client: the mutating endpoints enforce exactly the same rules, so a control you show from a flag will always be accepted.

POST /w/uploads

Issues a short-lived signed PUT URL for a screenshot upload. Upload the image to url, then pass uploadId when creating the thread.

response
// POST /w/uploads: no body required
// → 200
{
  "uploadId": "shots/<projectId>/…",  // pass as uploadId when creating the thread
  "url": "…"                          // signed PUT URL; upload the PNG here
}