---
source: https://threa.io/developers/operations
notes: Markdown mirror generated from the page above. YOUR_WORKSPACE_ID is the ws_… id in the app URL after /w/; YOUR_API_KEY is a key from Settings > API keys.
---

# 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:*

```json
{
  "error": "Validation failed",
  "details": {
    "query": ["query is required"],
    "limit": ["Number must be less than or equal to 50"]
  }
}
```

| Status | Means |
| --- | --- |
| `400` | Request failed schema validation. Check `details`. |
| `401` | Missing, malformed, revoked, or expired key. Also if the key owner is no longer active. |
| `403` | Authenticated, but not allowed to touch this resource. |
| `404` | Resource not found, or your key lacks the scope to see it exists. |
| `204` | Success with no body (e.g. a delete). |
| `429` | Rate 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:*

```bash
curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams?limit=50" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

*response tail:*

```json
{
  "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:*

```bash
curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams/STREAM_ID/messages \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -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.

| Limit | Window | Scope | Applies to |
| --- | --- | --- | --- |
| **60 requests** | 60s | per API key | every endpoint |
| **600 requests** | 60s | per workspace | every endpoint |
| **300 requests** | 60s | per client IP | baseline, all routes |
| **20 requests** | 60s | per caller | attachment 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:*

```text
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.
