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:
Authorization: Bearer cmt_pk_..., the project’s publishable key. The project is derived only from this key; no client-supplied ID is ever trusted for scoping.Origin, which must exactly match one of the project’s allowed origins (scheme + host + port). Requests from other origins are rejected withorigin_not_allowed. All endpoints answer CORS preflights.
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.
POST /w/identify HTTP/1.1
Origin: https://staging.example.com
Authorization: Bearer cmt_pk_yourkey...
Content-Type: application/jsonErrors 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": "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
| Endpoint | Token | Purpose |
|---|---|---|
POST /w/identify | none | Name + email in, commenter token out. |
POST /w/code | none | 6-digit code sign-in for claimed accounts on a new device. |
GET /w/threads?path= | optional | Threads (with messages) for a page path. |
POST /w/threads | required | Create a thread with its first message. |
POST /w/threads/:id/messages | required | Reply to a thread. |
DELETE /w/messages/:id | required | Soft-delete a message the token owns. |
POST /w/threads/:id/resolve | required | Resolve or reopen a thread you authored, or any thread as an editor. |
DELETE /w/threads/:id | required | Delete a thread you authored, or any thread as an editor. |
GET /w/session | optional | Report whether the presented editor token is still valid. |
POST /w/uploads | required | Issue 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.
// POST /w/identify: request body
{
"name": "Jane Doe", // 1-100 chars
"email": "jane@example.com" // lowercased, max 320 chars
}// 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.
// POST /w/code: two request forms
{ "email": "jane@example.com" } // 1: email a 6-digit code
{ "email": "jane@example.com", "code": "123456" } // 2: verify → IdentifyResponseGET /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
{
"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 (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
// 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
// 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.
// 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
}