Threa Developers

Operations

Running in production.

How errors come back, how paging works, how to send a message exactly once, and the rate limits in force. Plus the CORS setting that gates in-browser calls.

Errors

Errors are JSON with a human-readable error and, for validation failures, a details map of field to reasons:

400 Bad Request
{
  "error": "Validation failed",
  "details": {
    "query": ["query is required"],
    "limit": ["Number must be less than or equal to 50"]
  }
}
StatusMeans
400Request failed schema validation. Check details.
401Missing, malformed, revoked, or expired key. Also if the key owner is no longer active.
403Authenticated, but not allowed to touch this resource.
404Resource not found, or your key lacks the scope to see it exists.
204Success with no body (e.g. a delete).
429Rate limited. See below.

Pagination

List endpoints are cursor-paginated. Pass limit for page size and after to continue. Each response carries hasMore and an opaque cursor; feed that cursor back as after until hasMore is false.

first page
curl "/api/v1/workspaces//streams?limit=50" \
  -H "Authorization: Bearer "
response tail
{
  "data": [ /* … */ ],
  "hasMore": true,
  "cursor": "eyJ2IjoxLCJzIjoiMjAyNi0wNS0yOC…"
}
Two exceptions Listing messages in a stream pages by message sequence via before/after (numeric), with a hasMore flag and no opaque cursor. And when you pass a free-text query to a list endpoint, results rank by relevance and cursoring is turned off.

Idempotency

Sending a message accepts an optional clientMessageId (up to 128 characters). Retrying with the same stream and clientMessageId won't create a duplicate. You get the original message back, with the same 201. Generate one ID per logical message and reuse it across retries.

idempotent send
curl -X POST /api/v1/workspaces//streams/STREAM_ID/messages \
  -H "Authorization: Bearer " \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Deploy finished: v2.4.1 is live.",
    "clientMessageId": "deploy-2.4.1-notify"
  }'

Replace STREAM_ID with a real stream (list streams above to find one). The dedupe is a database constraint, so it holds even for concurrent retries.

Rate limits

Several limits stack, each over a rolling 60-second window. A request has to clear all of them, so the tightest one that applies is what you actually feel. These are the current defaults and may be tuned.

LimitWindowScopeApplies to
60 requests60sper API keyevery endpoint
600 requests60sper workspaceevery endpoint
300 requests60sper client IPbaseline, all routes
20 requests60sper callerattachment uploads

For a single key on a single host, the 60/min per key limit is the one you'll hit first. The per-IP baseline matters when many keys share one machine; the per-workspace ceiling caps everyone together. Uploads are held tighter still.

Whichever you hit first returns 429. The body reports the limit and window, and every response carries the standard RateLimit-* headers so you can back off before you're cut off:

429 + headers
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 23

{ "error": "Rate limit exceeded", "limit": 60, "windowMs": 60000 }

Watch RateLimit-Remaining and pause until RateLimit-Reset seconds have passed. To go faster, spread work across keys and hosts rather than pushing one key past 60/min.

CORS

Why "Run" may fail here Calls you run on these pages go straight from your browser to the base URL. A browser only allows that if the API has added this site's origin to its CORS allow-list. Until then, runnable samples report a CORS block. The same request still works from curl or your own server, which aren't subject to it.

The allow-list is the CORS_ALLOWED_ORIGINS environment variable on the backend (comma-separated origins). Add the docs origin there and the in-browser Run button works. For your own integrations this never applies: a server or CLI client has no preflight to satisfy.