---
title: "REST API"
description: "BackTalk's public REST API (v1): bearer authentication with workspace API keys, read/write scopes, every endpoint with curl examples, keyset pagination, stable error codes, and per-key rate limits."
source: https://backtalk.sh/docs/api
---
# REST API

BackTalk has a public REST API for reading and triaging signals (one at a time or in bulk), listing your keywords and watchers, pulling the report, reading and curating AI share-of-voice answers, and triggering a sync or an AI probe. It's a server-to-server surface: authenticate with a workspace **API key** as a bearer token. There are no cookies and no CORS headers — call it from your backend, a script, or a cron job, not a browser.

Base URL: `https://backtalk.sh/api/v1`

## Authentication

Create a key in **Settings → API keys** (admin or owner only). The token is shown **once**, at creation — store it somewhere safe. Send it on every request:

```bash
curl https://backtalk.sh/api/v1/me \
  -H "Authorization: Bearer btk_YOUR_KEY"
```

A key is tied to one workspace. It stops working the moment it's **revoked**, **expires**, or the member who created it **leaves the workspace**. Tokens are stored only as a SHA-256 hash; BackTalk can't show you a key again or recover a lost one — revoke it and make a new one.

A key also **follows its creator's current role**, re-checked on every request rather than fixed at creation. Demote the creator to [viewer](/docs/members) and their keys turn read-only straight away: reads keep working, writes answer `403 forbidden`. The key isn't revoked and its scopes don't change, so promoting them back to member or admin restores writing on the same token. Removing them from the workspace is the permanent version: every request, reads included, then answers `401`.

Every response sends `Cache-Control: no-store`.

## Scopes

Each key carries one or both scopes:

| Scope   | Grants |
| ------- | ------ |
| `read`  | `GET` signals, a single signal, keywords, watchers, the report, the AI share-of-voice endpoints, sync history, and probe status. |
| `write` | Everything in `read`, plus triaging signals (`PATCH /signals/:id` and bulk `PATCH /signals`), curating an AI answer (`PATCH /ai/answers/:id`), and triggering a sync (`POST /sync`) or an AI probe (`POST /ai/probe`). |

A request that needs a scope the key doesn't have returns `403 forbidden`. There is no config-write scope: keywords, watchers and webhooks are managed in the app, not over the API.

## Endpoints

### `GET /me`

Introspect the key — which workspace and scopes it carries. Useful for debugging.

```bash
curl https://backtalk.sh/api/v1/me -H "Authorization: Bearer btk_YOUR_KEY"
```

```json
{
  "workspace": { "id": "…", "name": "Acme's workspace" },
  "key": { "id": "…", "name": "CI pipeline", "prefix": "btk_1a2b3c4d", "scopes": ["read"] }
}
```

### `GET /signals`

List signals, newest first, with inbox-style filters. Requires `read`.

| Query param | Values |
| ----------- | ------ |
| `status`    | `unread`, `all` (default), `starred`, `replied`, `ignored` |
| `watcher`   | a watcher id, or `none` for keywords in no watcher |
| `keyword`   | comma-separated keyword ids (from `GET /keywords`); narrows within a watcher |
| `source`    | comma-separated source ids, e.g. `hackernews,github` |
| `sentiment` | comma-separated: `positive`, `neutral`, `negative`, `unknown` |
| `priority`  | comma-separated: `urgent`, `high`, `normal`, `low` |
| `category`  | comma-separated: `question`, `bug_report`, `complaint`, `praise`, `comparison`, `recommendation_ask`, `other` |
| `intent`    | comma-separated buying-intent strength: `high`, `medium`, `low` |
| `search`    | free-text match over title, snippet, author and url |
| `sort`      | `newest` (default) or `oldest` |
| `since` / `until` | ISO date (`2026-07-15`) or datetime — bounds on when the signal was found |
| `limit`     | 1–100 (default 50) |
| `cursor`    | opaque keyset cursor (see Pagination) |

```bash
curl -G https://backtalk.sh/api/v1/signals \
  -H "Authorization: Bearer btk_YOUR_KEY" \
  --data-urlencode "sentiment=negative" \
  --data-urlencode "since=2026-07-15" \
  --data-urlencode "limit=20"
```

```json
{
  "signals": [
    {
      "id": "…",
      "source_id": "hackernews",
      "title": "…",
      "snippet": "…",
      "url": "https://news.ycombinator.com/item?id=…",
      "state": "new",
      "starred": false,
      "sentiment": "negative",
      "found_at": "2026-07-15T09:12:00Z"
    }
  ],
  "next_cursor": "eyJmb3VuZF9hdCI6…"
}
```

### `GET /signals/:id`

Full detail for one signal. Requires `read`. A signal id from another workspace returns `404 not_found` — never another tenant's data.

### `PATCH /signals/:id`

Triage a signal. Requires `write`. Body (JSON, at least one of `state` / `starred` / `sentiment`):

```json
{ "state": "ignored", "ignore_reason_chip": "job_ad", "ignore_reason_text": "recruiter post" }
```

- `state`: `new`, `seen`, `replied`, or `ignored`. (`spam` is an AI classification, not a triage action.)
- `starred`: `true` / `false`.
- `sentiment`: `positive`, `neutral`, `negative`, `unknown`. Overrides the label as a manual correction that outranks the automatic scorer.
- `ignore_reason_chip`: `not_relevant`, `job_ad`, `different_product`, `spam`. Only meaningful when `state` is `ignored`.
- `ignore_reason_text`: free text (≤ 500 chars).

```bash
curl -X PATCH https://backtalk.sh/api/v1/signals/SIGNAL_ID \
  -H "Authorization: Bearer btk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"starred": true}'
```

Returns the updated signal.

### `PATCH /signals` (bulk)

Apply one triage patch to many signals at once. Requires `write`. Body (JSON): an `ids` array (1–100) plus the same fields as the single-signal `PATCH` (at least one of `state` / `starred` / `sentiment`).

```bash
curl -X PATCH https://backtalk.sh/api/v1/signals \
  -H "Authorization: Bearer btk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ids": ["…", "…"], "state": "seen"}'
```

```json
{ "updated": 2, "ids": ["…", "…"] }
```

`updated` and `ids` report exactly what changed: unknown, cross-tenant, or past-retention ids simply don't match, so a partial batch is a success, not an error.

### `GET /keywords` and `GET /watchers`

List the workspace's keywords (phrase, variants, exclude terms, active state) and watchers. Both require `read`.

### `GET /report`

The `/app/report` rollup for a window: volume, replied count, oldest-unread age, sentiment (positive/neutral/negative and net = positive − negative), per-source counts, and a zero-filled trend. Requires `read`.

Pass a rolling preset with `period` = `today` (default) | `7d` | `30d`, or a **custom range** with `since` / `until` (ISO date or datetime; `until` defaults to now). When `since` is given it wins and `period` is ignored, and `period` in the response reads `"custom"`. Custom ranges are capped at 366 days; a wider or inverted range returns `400 invalid_request`.

```bash
curl "https://backtalk.sh/api/v1/report?period=today" -H "Authorization: Bearer btk_YOUR_KEY"
curl -G "https://backtalk.sh/api/v1/report" -H "Authorization: Bearer btk_YOUR_KEY" \
  --data-urlencode "since=2026-07-01" --data-urlencode "until=2026-07-31"
```

### `GET /ai/topics`

The workspace's AI share-of-voice topics: each is one buying decision with its prompts, your brand aliases, and the competitors to watch. Requires `read`.

### `GET /ai/share-of-voice?topic=&window=7|30`

Standings for one topic: how often AI models (Claude, ChatGPT, Gemini, Perplexity) recommend you vs competitors. Requires `read`. Omit `topic` for the first topic with data; `window` defaults to `30`. Until the first probe has run, the response has `"available": false`. When sampled answers mention no tracked brand at all, `rank` (and `by_model[].your_rank`) is `null`: no real ranking exists yet.

The blended `share_pct` weights every compared model equally: it is the average of each model's own share, not a pooling of all answers, so a model you probe more often than another does not skew it. Each `by_model[]` entry is that one model's own share. Because the blend is an average of per-model distributions, `share_pct` across tracked brands still sums to about 100.

```bash
curl "https://backtalk.sh/api/v1/ai/share-of-voice?window=7" -H "Authorization: Bearer btk_YOUR_KEY"
```

```json
{
  "available": true,
  "topic": { "id": "…", "name": "Payment APIs" },
  "window_days": 7,
  "runs_sampled": 420,
  "previous_window_sampled": true,
  "standings": [
    { "brand": "Stripe", "is_you": false, "share_pct": 41.2, "previous_share_pct": 43.0, "rank": 1 },
    { "brand": "Acme", "is_you": true, "share_pct": 28.4, "previous_share_pct": 24.1, "rank": 2 }
  ],
  "by_model": [{ "family": "claude", "your_share_pct": 31.0, "your_rank": 2 }],
  "trend": [{ "day": "2026-07-14", "share_pct": { "Acme": 27.5, "Stripe": 42.1 } }],
  "families": ["claude", "chatgpt", "gemini", "perplexity"]
}
```

A family id names one compared model. It stays the short form above while a provider contributes a single model; when a workspace compares several models from the same provider, each appears as its own `provider:model` entry (for example `"venice:llama-3.3-70b"`).

When `previous_window_sampled` is `false`, the previous window had no sampled answers, and every `previous_share_pct` is `0` by convention rather than a measurement. Suppress deltas in that case instead of showing a movement from zero.

### `GET /ai/answers`

The sampled AI answers behind those numbers, newest first, keyset-paginated like `/signals`. Requires `read`. Each row carries the model, the prompt asked, the answer excerpt, the extracted brand mentions, and any citations the model returned.

| Query param | Values |
| ----------- | ------ |
| `topic`     | a topic id (see `GET /ai/topics`) |
| `model`     | a model family (`claude`, `chatgpt`, `gemini`, `perplexity`), a provider id, or a `provider:model` pair to pin one model |
| `starred`   | `true` / `false` to filter on answers starred in the app |
| `limit`     | 1–100 (default 25) |
| `cursor`    | opaque keyset cursor (see Pagination) |

### `PATCH /ai/answers/:id`

Curate one sampled answer. Requires `write`. Body (JSON, at least one of `starred` / `flagged`):

- `starred`: `true` / `false`. A "refer back to this" bookmark (the same shelf the `starred` filter on `GET /ai/answers` reads).
- `flagged`: `true` / `false`. Marks a wrong extraction (a mis-attributed brand or stance); doubles as judge feedback.

An answer id from another workspace returns `404 not_found`.

```bash
curl -X PATCH https://backtalk.sh/api/v1/ai/answers/ANSWER_ID \
  -H "Authorization: Bearer btk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"flagged": true}'
```

Returns the updated answer.

### `GET /ai/probe`

Current probe status. Requires `read`. Reports whether a probe is running (with live `done`/`total` progress) and today's manual-probe allowance.

```bash
curl https://backtalk.sh/api/v1/ai/probe -H "Authorization: Bearer btk_YOUR_KEY"
```

```json
{ "running": false, "progress": { "done": 0, "total": 0 }, "used_today": 1, "remaining": 5, "limit": 6 }
```

`remaining` and `limit` are `null` when the plan has no per-day cap.

### `POST /ai/probe`

Trigger a manual AI share-of-voice probe: ask the AI models each topic's prompts and record who they recommend. Requires `write`. No body. The probe runs in the background, so this returns a `run_id` right away; poll `GET /ai/probe` for progress, or `GET /ai/share-of-voice` / `GET /ai/answers` for results a minute or two later.

```bash
curl -X POST https://backtalk.sh/api/v1/ai/probe -H "Authorization: Bearer btk_YOUR_KEY"
```

```json
{ "ok": true, "run_id": "…", "remaining": 5 }
```

Reuses the in-app "Probe now" gate: a per-plan daily cap (or sponsored grants) and one run at a time, so calling too often returns `429` with `Retry-After`. A workspace with no AI key enabled for probing returns `400 invalid_request`. `remaining` is the manual probes left today (`null` when unlimited).

### `POST /sync`

Trigger a manual sync for the workspace. Requires `write`. This reuses the in-app "Sync now" gate: a workspace-wide cooldown and a per-plan daily cap. When you hit either, you get `429` with a `Retry-After` header.

```bash
curl -X POST https://backtalk.sh/api/v1/sync -H "Authorization: Bearer btk_YOUR_KEY"
```

### `GET /syncs`

Sync-run history, newest first, keyset-paginated like `/signals`. Requires `read`. Each run carries its `trigger` (`manual` / `cron` / `backfill`), a derived `status` (`ok`, `error`, `running`, `aborted`, `partial`), `totals`, and a per-source breakdown — the same view the in-app **Sync history** page renders.

| Query param | Values |
| ----------- | ------ |
| `limit`     | 1–100 (default 25) |
| `cursor`    | opaque keyset cursor (see Pagination) |

```bash
curl https://backtalk.sh/api/v1/syncs -H "Authorization: Bearer btk_YOUR_KEY"
```

## Pagination

`GET /signals`, `GET /ai/answers` and `GET /syncs` use keyset (cursor) pagination, ordered by time then `id`, descending by default and ascending for `GET /signals?sort=oldest`. When a page is full there's a `next_cursor`; pass it back as `?cursor=…` for the next page. When `next_cursor` is `null`, you've reached the end. Cursors are opaque, so don't construct or parse them.

## Errors

Every non-2xx response is the same envelope:

```json
{ "error": { "code": "forbidden", "message": "This API key needs the \"write\" scope." } }
```

| Code | HTTP | When |
| ---- | ---- | ---- |
| `unauthorized` | 401 | Missing/invalid/revoked/expired key, or its creator left the workspace. Sends `WWW-Authenticate: Bearer`. |
| `forbidden` | 403 | The key lacks the scope the endpoint needs, or its creator is now a viewer (read-only), so writes are refused. |
| `workspace_read_only` | 403 | The workspace is read-only (lapsed trial / inactive subscription). Reads still work; writes are blocked. |
| `not_found` | 404 | No such resource in this workspace. |
| `invalid_request` | 400 | Failed validation, malformed JSON, or an oversized body. |
| `rate_limited` | 429 | Too many requests (or sync cooldown/cap). Sends `Retry-After`. |
| `internal` | 500 | Something failed on our end. The message is generic; details are in our logs. |

Branch on `code`, not on the message text.

## Rate limits

Each key is limited to **120 requests per minute** (fixed window). Every response includes:

- `X-RateLimit-Limit` — the per-minute ceiling.
- `X-RateLimit-Remaining` — requests left in the current window.
- `X-RateLimit-Reset` — Unix seconds when the window resets.

Over the limit returns `429 rate_limited` with `Retry-After`. Limits are per key, so give each integration its own key — a workspace's total throughput therefore scales with its number of active keys (up to 20 keys per workspace).

Repeated **failed** authentication attempts are also throttled (per client IP). If you're getting `429` on a key you believe is valid, check the `Authorization` header format first: `Bearer btk_…`.
