Public API
Read your sites, threads and comments from your own server on every plan, and moderate them on the Starter plan and above.
Last updated · Manage tokens from Account → API Tokens in the dashboard.
Overview
The public API lets a script or server you control read a site's threads and comments and take moderation actions — approve, reject, delete — without a person in the dashboard. It is a REST API over HTTPS: every request and response body is JSON.
Every route lives under one base URL:
https://api.echothread.io/api/public/v1This is a separate address from the dashboard's own /api/v1, and a separate credential space too — the public API is authenticated with a personal API token (below), never with the session cookie your browser uses when you're signed in to the dashboard.
Reading is on every plan; writing needs Starter. A free Hobby account can create up to 2 read-only tokens. Moderating and deleting need the Starter plan or above. See pricing for plan details.
Authentication
Every request carries a personal API token as a bearer credential in the Authorization header:
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5cA token always starts with et_. It authenticates as your account across every site that account owns — there's no per-site scoping — and it can do only what its scopes allow. Treat it like a password.
Creating a token
- Open Account → API Tokens in the dashboard.
- Click Create token and give it a name that describes what will use it — e.g. "deploy pipeline" or "nightly digest script".
- Optionally set an expiry (30 days, 90 days, or 1 year) so it stops working on its own without you having to remember to revoke it.
- Choose its scopes. Read, moderate and reply are ticked by default; tick delete only if the token needs to delete comments. On Hobby only read is available, and you can hold up to 2 active tokens.
- Copy the plaintext token shown on screen. It's displayed exactly once — after you leave the page, EchoThread only ever shows a masked fingerprint like
et_live_9f2a1c…, and there is no way to recover the full value again.
Scopes
Each route needs exactly one scope. A token without it gets a 403 scope_required and nothing changes. Give a token only what the script using it needs.
| Scope | Allows | Plan |
|---|---|---|
| read | Every GET route: sites, threads, comments and stats. The new-comments feed also needs Starter. | Every plan |
| moderate | Approve, reject and mark comments as spam; find or create a thread. | Starter and above |
| reply | Reply to comments as your account. | Starter and above |
| delete | Permanently delete comments. | Starter and above |
Tokens created before September 30, 2026 have all four scopes, so nothing that already uses one stops working.
Revoking a token
From the same Account → API Tokens page, click Revoke next to any token — live or already expired — and confirm. Revocation is immediate: the very next request made with that token fails authentication. A revoked token stays listed (marked revoked) so you always have a record of what once had access.
Listing and revoking your tokens never requires a specific plan. If your account moves to Hobby, tokens you already created stay visible and revocable and keep reading; their moderate and delete calls return 403 feature_required until you upgrade again.
Pagination
The two list routes — threads for a site, and comments for a thread — use cursor pagination instead of page numbers. Every list response has the same shape:
{
"threads": [ /* … */ ],
"next_cursor": "eyJzaXRlX2lkIjoic2l0ZV80YjJlOWExZCJ9"
}?limit=— how many items to return, from 1 to 100. Defaults to 20 if omitted.?cursor=— an opaque token from a previous response'snext_cursor. Omit it to fetch the first page.next_cursorisnullonce there are no more pages — stop paging when you see it, rather than looping until you get an empty array.
A cursor is only valid against the resource it was minted for — a cursor from paging a site's threads can't be reused to page a different site's threads, or a thread's comments. Treat it as opaque: don't decode, construct, or edit one by hand.
Read endpoints
Every route below is scoped to sites your account owns. A site, thread or comment id that doesn't exist — or that exists but belongs to a different account — returns the same 404 either way; see Errors.
| Method | Path | Description |
|---|---|---|
| GET | /sites | Every site your account owns. |
| GET | /sites/{site_id}/threads | Threads on one site, cursor-paginated. |
| GET | /threads/{thread_id} | A single thread by id. |
| GET | /threads/{thread_id}/comments | Comments on one thread, cursor-paginated. |
| GET | /comments/{comment_id} | A single comment by id. |
| GET | /sites/{site_id}/comments | One site's pending comments across every thread, newest first. |
| GET | /sites/{site_id}/stats | Comment counts by status for the last 7 and 30 days. |
| GET | /comments?since= | New comments on every site you own, oldest first. Starter and above. |
GET /sites
Every site your account owns. Not paginated — your site count is bounded by your plan's site limit, not by user-generated content.
GET https://api.echothread.io/api/public/v1/sites
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c[
{
"id": "site_4b2e9a1d",
"name": "My Blog",
"shortname": "my-blog",
"domain": "example.com",
"api_key": "eak_7c1a9b3e2d4f5061",
"auto_approve": false,
"spam_filter": true,
"allow_voting": true,
"allow_guest_comments": true,
"max_nesting_depth": 5,
"is_active": true,
"created_at": "2026-03-04T09:12:00Z",
"updated_at": "2026-08-01T17:40:22Z"
}
]GET /sites/{site_id}/threads
Threads on one site, newest first by last comment. Cursor-paginated (see above).
| Parameter | Type | Description |
|---|---|---|
| site_id | path | The site's id. |
| limit | query, optional | 1–100, default 20. |
| cursor | query, optional | Opaque token from a previous next_cursor. |
GET https://api.echothread.io/api/public/v1/sites/site_4b2e9a1d/threads?limit=20
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c{
"threads": [
{
"id": "thr_2d8c4f1a9b3e",
"site_id": "site_4b2e9a1d",
"page_url": "https://example.com/blog/my-post",
"identifier": "blog-my-post",
"title": "My Blog Post",
"comment_count": 12,
"approved_count": 10,
"pending_count": 2,
"is_open": true,
"is_locked": false,
"created_at": "2026-08-01T10:03:00Z",
"updated_at": "2026-08-18T14:32:07Z",
"last_comment_at": "2026-08-18T14:32:07Z"
}
],
"next_cursor": null
}GET /threads/{thread_id}
A single thread by id.
GET https://api.echothread.io/api/public/v1/threads/thr_2d8c4f1a9b3e
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c{
"id": "thr_2d8c4f1a9b3e",
"site_id": "site_4b2e9a1d",
"page_url": "https://example.com/blog/my-post",
"identifier": "blog-my-post",
"title": "My Blog Post",
"comment_count": 12,
"approved_count": 10,
"pending_count": 2,
"is_open": true,
"is_locked": false,
"created_at": "2026-08-01T10:03:00Z",
"updated_at": "2026-08-18T14:32:07Z",
"last_comment_at": "2026-08-18T14:32:07Z"
}GET /threads/{thread_id}/comments
Comments on one thread, newest first. Cursor-paginated. Add ?status= (pending, approved, rejected) to filter to one moderation state.
GET https://api.echothread.io/api/public/v1/threads/thr_2d8c4f1a9b3e/comments?status=pending&limit=20
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c{
"comments": [
{
"id": "cmt_7a1f3e9b2c4d",
"thread_id": "thr_2d8c4f1a9b3e",
"parent_id": null,
"body": "This is exactly the kind of nuance I was hoping someone would bring up.",
"author": {
"id": "usr_9c3b7e1f2a4d",
"display_name": "Jordan Lee",
"avatar_url": "https://echothread.io/avatars/9c3b7e1f2a4d.png"
},
"guest_name": null,
"status": "pending",
"reply_count": 0,
"depth": 0,
"is_edited": false,
"is_pinned": false,
"is_author_site_owner": false,
"created_at": "2026-08-18T14:32:07Z",
"updated_at": "2026-08-18T14:32:07Z"
}
],
"next_cursor": null
}GET /comments/{comment_id}
A single comment by id.
GET https://api.echothread.io/api/public/v1/comments/cmt_7a1f3e9b2c4d
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c{
"id": "cmt_7a1f3e9b2c4d",
"thread_id": "thr_2d8c4f1a9b3e",
"parent_id": null,
"body": "This is exactly the kind of nuance I was hoping someone would bring up.",
"author": {
"id": "usr_9c3b7e1f2a4d",
"display_name": "Jordan Lee",
"avatar_url": "https://echothread.io/avatars/9c3b7e1f2a4d.png"
},
"guest_name": null,
"status": "pending",
"reply_count": 0,
"depth": 0,
"is_edited": false,
"is_pinned": false,
"is_author_site_owner": false,
"created_at": "2026-08-18T14:32:07Z",
"updated_at": "2026-08-18T14:32:07Z"
}GET /sites/{site_id}/comments?status=pending
The moderation queue for one site: its pending comments across every thread, newest first, 50 a page. Cursor-paginated. status is optional and only pending is accepted. Each comment carries its site_id.
GET https://api.echothread.io/api/public/v1/sites/site_4b2e9a1d/comments?status=pending
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5cGET /comments?since=
Every comment created after since on every site your account owns, oldest first, 100 a page — pending, approved, spam and rejected alike. Built for polling: store the created_at of the newest comment you've seen and pass it as since next time. since is required and must be an RFC 3339 timestamp such as 2026-09-30T12:00:00Z; follow next_cursor (with the same since) until it is null. Needs the Starter plan or above (a Starter trial counts); Hobby gets 403 feature_required.
GET https://api.echothread.io/api/public/v1/comments?since=2026-09-30T12:00:00Z
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5cGET /sites/{site_id}/stats
How many comments the site received in the last 7 and 30 days, by the status each one holds now. Windows are rolling, counted back from generated_at.
GET https://api.echothread.io/api/public/v1/sites/site_4b2e9a1d/stats
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c{
"site_id": "site_4b2e9a1d",
"generated_at": "2026-09-30T12:00:00Z",
"last_7_days": { "approved": 42, "pending": 3, "spam": 17, "rejected": 1 },
"last_30_days": { "approved": 180, "pending": 3, "spam": 64, "rejected": 5 }
}Moderate endpoints
Approve, reject, mark as spam, reply to and delete comments, and find-or-create a thread ahead of publishing a page. Every action here is identical — in stored state, counters and any webhook it fires — to the same action taken from the dashboard's moderation queue.
| Method | Path | Description |
|---|---|---|
| POST | /comments/{comment_id}/approve | Approve a pending comment. |
| POST | /comments/{comment_id}/reject | Reject a comment. |
| POST | /comments/{comment_id}/spam | Mark a comment as spam. |
| POST | /comments/{comment_id}/reply | Reply to a comment as your account. |
| POST | /comments/{comment_id}/delete | Permanently delete a comment and its replies. |
| POST | /sites/{site_id}/threads | Find or create a thread for a page. |
A token never posts as a visitor. The only comment the API can create is a reply, and it is posted as your own account — exactly as if you had replied from the dashboard. Every other comment is created by a real visitor through the embed widget.
POST /comments/{comment_id}/approve
Transitions a comment to approved. Requires an owner or moderator seat on the comment's site.
POST https://api.echothread.io/api/public/v1/comments/cmt_7a1f3e9b2c4d/approve
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c{
"id": "cmt_7a1f3e9b2c4d",
"thread_id": "thr_2d8c4f1a9b3e",
"parent_id": null,
"body": "This is exactly the kind of nuance I was hoping someone would bring up.",
"author": {
"id": "usr_9c3b7e1f2a4d",
"display_name": "Jordan Lee",
"avatar_url": "https://echothread.io/avatars/9c3b7e1f2a4d.png"
},
"guest_name": null,
"status": "approved",
"reply_count": 0,
"depth": 0,
"is_edited": false,
"is_pinned": false,
"is_author_site_owner": false,
"created_at": "2026-08-18T14:32:07Z",
"updated_at": "2026-08-18T14:36:00Z"
}POST /comments/{comment_id}/reject
Transitions a comment to rejected. Same request shape as approve, with "status": "rejected" in the response.
POST https://api.echothread.io/api/public/v1/comments/cmt_2e8b4d1a7c9f/reject
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5cPOST /comments/{comment_id}/spam
Marks a comment as spam, with "status": "spam" in the response. Like the dashboard's spam action, it fires a comment.rejected webhook whose comment.status is spam.
POST https://api.echothread.io/api/public/v1/comments/cmt_2e8b4d1a7c9f/spam
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5cPOST /comments/{comment_id}/reply
Replies to a comment as your account. The reply is published straight away, as a reply from the site's owner is in the dashboard, and the person you replied to gets their usual reply notification. body is plain text, 1 to 10,000 characters; anything else returns 422 with "code": "invalid_body". Returns 201 with the new comment.
POST https://api.echothread.io/api/public/v1/comments/cmt_7a1f3e9b2c4d/reply
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c
Content-Type: application/json
{ "body": "Thanks for reading!" }POST /comments/{comment_id}/delete
Permanently deletes a comment and its reply subtree. Returns 204 No Content — there's nothing left to serialize.
POST https://api.echothread.io/api/public/v1/comments/cmt_7a1f3e9b2c4d/delete
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5cHTTP/1.1 204 No ContentPOST /sites/{site_id}/threads
Finds or creates a thread for a page on a site you own — useful for pre-creating a thread from a publishing pipeline before a page goes live. Calling it twice with the same page_url/identifier is safe: the second call returns the existing thread rather than erroring.
| Field | Type | Description |
|---|---|---|
| page_url | string, required | The absolute http(s) URL the thread belongs to. |
| identifier | string, optional | A stable key for the thread. Defaults to page_url when omitted. |
| title | string, optional | Shown in the moderation dashboard and notifications. |
POST https://api.echothread.io/api/public/v1/sites/site_4b2e9a1d/threads
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c
Content-Type: application/json
{
"page_url": "https://example.com/blog/new-post",
"identifier": "blog-new-post",
"title": "New Blog Post"
}{
"id": "thr_9f1a3c7e2b5d",
"site_id": "site_4b2e9a1d",
"page_url": "https://example.com/blog/new-post",
"identifier": "blog-new-post",
"title": "New Blog Post",
"comment_count": 0,
"approved_count": 0,
"pending_count": 0,
"is_open": true,
"is_locked": false,
"created_at": "2026-08-18T14:50:00Z",
"updated_at": "2026-08-18T14:50:00Z",
"last_comment_at": null
}Errors
Every error response is JSON with a detail field — a string for most errors, or a list of field-level problems for a 422 validation failure.
401 — not authenticated
The Authorization header is missing or malformed, or the token is unrecognized, revoked, or expired. Every one of those cases returns the exact same body and status — on purpose, so a caller probing tokens can't tell "never existed" from "revoked" apart.
{
"detail": "Could not validate credentials"
}403 — scope required
Your token is valid, but it wasn't created with the scope this route needs. No plan fixes this: create a token with that scope. scope names the one that's missing.
{
"detail": "This API token does not have the delete scope. Create a token with it on the API tokens page.",
"code": "scope_required",
"scope": "delete"
}403 — feature required
Your token has the scope, but your account's plan doesn't include writing through the API — usually because it moved from Starter to Hobby after the token was created. This is deliberately a different error from 401: a correctly-configured integration needs to hear "upgrade your plan," not "your credential is wrong."
{
"detail": "Your Hobby plan does not include this feature (webhooks_api). Starter includes it, from $5/month — upgrade at /pricing.",
"code": "feature_required",
"feature": "webhooks_api",
"plan": "hobby",
"upgrade_plan": "starter",
"upgrade_plan_monthly_usd": 5,
"upgrade_url": "/pricing"
}403 / 404 — a site you don't own
A site, thread or comment id that doesn't exist, and one that exists but belongs to a different account, both return the same 404 — so a caller walking ids can't distinguish the two. If the id is genuinely yours but the seat your account holds on it doesn't allow the action (for example a moderator-only seat calling an owner-only route), that's a 403 instead, since at that point you've already proven the resource is on your own account.
{
"detail": "Site not found"
}422 — validation error
A malformed request — an invalid cursor, an out-of-range limit, or a missing required field. detail is a list so more than one problem can be reported at once.
{
"detail": [
{
"loc": ["query", "cursor"],
"msg": "value is not a valid cursor",
"type": "value_error"
}
]
}