Skip to content

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/v1

This 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_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c

A 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

  1. Open Account → API Tokens in the dashboard.
  2. Click Create token and give it a name that describes what will use it — e.g. "deploy pipeline" or "nightly digest script".
  3. 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.
  4. 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.
  5. 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.

ScopeAllowsPlan
readEvery GET route: sites, threads, comments and stats. The new-comments feed also needs Starter.Every plan
moderateApprove, reject and mark comments as spam; find or create a thread.Starter and above
replyReply to comments as your account.Starter and above
deletePermanently 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's next_cursor. Omit it to fetch the first page.
  • next_cursor is null once 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.

MethodPathDescription
GET/sitesEvery site your account owns.
GET/sites/{site_id}/threadsThreads on one site, cursor-paginated.
GET/threads/{thread_id}A single thread by id.
GET/threads/{thread_id}/commentsComments on one thread, cursor-paginated.
GET/comments/{comment_id}A single comment by id.
GET/sites/{site_id}/commentsOne site's pending comments across every thread, newest first.
GET/sites/{site_id}/statsComment 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.

Request
GET https://api.echothread.io/api/public/v1/sites
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c
200 Response
[
  {
    "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).

ParameterTypeDescription
site_idpathThe site's id.
limitquery, optional1–100, default 20.
cursorquery, optionalOpaque token from a previous next_cursor.
Request
GET https://api.echothread.io/api/public/v1/sites/site_4b2e9a1d/threads?limit=20
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c
200 Response
{
  "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.

Request
GET https://api.echothread.io/api/public/v1/threads/thr_2d8c4f1a9b3e
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c
200 Response
{
  "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.

Request
GET https://api.echothread.io/api/public/v1/threads/thr_2d8c4f1a9b3e/comments?status=pending&limit=20
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c
200 Response
{
  "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.

Request
GET https://api.echothread.io/api/public/v1/comments/cmt_7a1f3e9b2c4d
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c
200 Response
{
  "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.

Request
GET https://api.echothread.io/api/public/v1/sites/site_4b2e9a1d/comments?status=pending
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c

GET /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.

Request
GET https://api.echothread.io/api/public/v1/comments?since=2026-09-30T12:00:00Z
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c

GET /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.

Request
GET https://api.echothread.io/api/public/v1/sites/site_4b2e9a1d/stats
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c
200 Response
{
  "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.

MethodPathDescription
POST/comments/{comment_id}/approveApprove a pending comment.
POST/comments/{comment_id}/rejectReject a comment.
POST/comments/{comment_id}/spamMark a comment as spam.
POST/comments/{comment_id}/replyReply to a comment as your account.
POST/comments/{comment_id}/deletePermanently delete a comment and its replies.
POST/sites/{site_id}/threadsFind 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.

Request
POST https://api.echothread.io/api/public/v1/comments/cmt_7a1f3e9b2c4d/approve
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c
200 Response
{
  "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.

Request
POST https://api.echothread.io/api/public/v1/comments/cmt_2e8b4d1a7c9f/reject
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c

POST /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.

Request
POST https://api.echothread.io/api/public/v1/comments/cmt_2e8b4d1a7c9f/spam
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c

POST /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.

Request
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.

Request
POST https://api.echothread.io/api/public/v1/comments/cmt_7a1f3e9b2c4d/delete
Authorization: Bearer et_live_9f2a1c7b4e8d3f0a6b5c9d1e2f3a4b5c
Response
HTTP/1.1 204 No Content

POST /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.

FieldTypeDescription
page_urlstring, requiredThe absolute http(s) URL the thread belongs to.
identifierstring, optionalA stable key for the thread. Defaults to page_url when omitted.
titlestring, optionalShown in the moderation dashboard and notifications.
Request
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"
}
200 Response
{
  "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"
    }
  ]
}