# REST API: send feedback, read themes

> Camelus REST API reference: API keys and scopes, POST /v1/feedback, reading items and themes, changing a theme's status, limits and errors.

Send feedback to Camelus from any server, and read the themes it builds, with plain HTTP requests. Responses never contain original feedback text or personal data: only AI summaries and counts.

The machine-readable version of this page is the OpenAPI document at [api.camelus.si/v1/openapi.json](https://api.camelus.si/v1/openapi.json). It includes the webhook payloads too.

## Authentication

Create an API key in **Settings → API & MCP**. Keys look like `ck_live_abc123_…`, are shown once, and belong to a workspace, not to a person. Send the key as a bearer token:

```
Authorization: Bearer ck_live_abc123_…
```

Each key has scopes:

| Scope | Allows | Plans |
| --- | --- | --- |
| `feedback:write` | `POST /v1/feedback` | All |
| `read` | Reading items and themes, and the [MCP server](/docs/mcp) | Pro, Studio |
| `themes:write` | Changing a theme's status | Pro, Studio |

Keep keys on your server. Revoke a key in Settings when you no longer need it.

## Send feedback

```bash
curl https://api.camelus.si/v1/feedback \
  -H "Authorization: Bearer $CAMELUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "habits",
    "text": "Export uses US dates, I am in France.",
    "external_id": "zendesk-4412",
    "person": { "id": "u_123", "email": "ana@example.com", "name": "Ana" },
    "metadata": { "plan": "pro", "app_version": "2.3.1" }
  }'
```

| Field | Type | Notes |
| --- | --- | --- |
| `product` | string, required | The product's slug, shown on the product page. |
| `text` | string, required | The feedback itself. Text beyond 10,000 characters is cut, and the item is marked truncated. |
| `title` | string | Up to 200 characters. |
| `external_id` | string | Your id for this message (a ticket number, a row id). Sending the same id twice creates one item. |
| `person` | object | `id` (your user id), `email`, `name`. All optional. Without them the feedback is anonymous. |
| `metadata` | object | Anything useful for triage: plan, app version, browser. Up to 4 KB of JSON. |
| `page_url` | string | The page the feedback is about. |
| `created_at` | ISO 8601 date-time | When the feedback was given, for imports. Defaults to now. |

A new item returns `202 Accepted` with `{ "id": "k57c…", "status": "received" }`.

Retries are safe. A request with the same `external_id` as an earlier one, or with no `external_id` and the same text from the same person within 10 minutes, returns `200 OK` with the first item's id and `"duplicate": true`.

## Read themes

```bash
curl "https://api.camelus.si/v1/themes?product=habits&status=open&sort=score&limit=20" \
  -H "Authorization: Bearer $CAMELUS_API_KEY"
```

`sort` is `score` (the default), `requesters`, `mrr`, `trend` or `recent`. `limit` goes up to 100. Themes that have only one item so far (candidates) aren't listed.

Change a theme's status, with an optional note for its timeline:

```bash
curl -X PATCH https://api.camelus.si/v1/themes/k97d… \
  -H "Authorization: Bearer $CAMELUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "planned", "note": "On the roadmap for November" }'
```

Statuses are `open`, `planned`, `in_progress`, `shipped`, `declined` and `closed`. A change fires `theme.status_changed` to your [destinations](/docs/destinations), and `shipped` drafts an email to the people who asked.

## Reference

### POST /v1/feedback

Stores one feedback item. Returns 202 for a new item, 200 with the same id for a repeat (same external_id, or same text from the same person within 10 minutes). Scope: `feedback:write`.

### GET /v1/feedback/{id}

Processing results of one item: summary, type, sentiment, urgency and theme. No original text or personal data. Scope: `read`.

### GET /v1/themes

Themes ranked by score (or another sort), optionally for one product and status. Scope: `read`.

### GET /v1/themes/{id}

Summary, stats, up to 10 representative item summaries and linked external work. Scope: `read`.

### PATCH /v1/themes/{id}

Sets the status and adds an optional note to the timeline. Fires theme.status_changed to subscribed destinations. Scope: `themes:write`.

### Theme

Returned by `GET /v1/themes` (in `data`) and inside webhook payloads.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string |  |
| `product` | object |  |
| `title` | string |  |
| `summary` | string or null |  |
| `type` | `bug` · `feature_request` · `question` · `complaint` · `praise` · `other` |  |
| `status` | `open` · `planned` · `in_progress` · `shipped` · `declined` · `closed` |  |
| `score` | number |  |
| `trend` | `new` · `rising` · `falling` · `stable` |  |
| `requesters` | integer |  |
| `paying_requesters` | integer |  |
| `mrr_affected_cents` | integer or null | null when no billing connector is set up; never estimated |
| `item_count` | integer |  |
| `sentiment` | object |  |
| `last_item_at` | string or null |  |
| `url` | string | The theme in the Camelus app |

### Theme detail

Returned by `GET /v1/themes/{id}` and `PATCH /v1/themes/{id}`: every Theme field, plus:

| Field | Type | Notes |
| --- | --- | --- |
| `representative_items` | object[], optional | Up to 10, chosen by code: most recent, highest weight, diverse sources |
| `external_links` | object[], optional |  |

### Feedback item

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string |  |
| `product` | object |  |
| `source` | string |  |
| `status` | `received` · `processing` · `processed` · `needs_review` · `archived` · `spam` |  |
| `received_at` | string | ISO 8601 date-time, UTC |
| `external_id` | string or null |  |
| `language` | string or null |  |
| `summary_en` | string or null |  |
| `type` | `bug` · `feature_request` · `question` · `complaint` · `praise` · `other` or null |  |
| `sentiment` | `negative` · `neutral` · `positive` or null |  |
| `urgency` | `low` · `medium` · `high` or null |  |
| `theme_id` | string or null |  |

## Limits

- 60 requests a minute per API key. Past that, the API answers `429` with a `Retry-After` header.
- Request bodies up to 128 KB.
- Past your plan's monthly item limit, feedback is still accepted and stored. It's never dropped.

## Errors

Errors use standard HTTP status codes and a JSON body:

```json
{ "error": { "code": "invalid_body", "message": "person.email: Invalid email address" } }
```

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `invalid_json`, `invalid_body`, `invalid_query` | The body isn't JSON, or a field or parameter is missing or invalid. The message names it. |
| 401 | `unauthorized` | The key is missing, malformed or revoked. |
| 403 | `forbidden`, `plan_required`, `source_disabled` | The key lacks the scope, the plan doesn't include the read API, or the product's API source is turned off. |
| 404 | `product_not_found`, `not_found` | No such product slug, item or theme in the key's workspace. |
| 409 | `merged` | The theme was merged into another one. |
| 413 | `too_large` | The body is too big. |
| 429 | `rate_limited` | Too many requests for this key. Wait for the number of seconds in `Retry-After`. |
