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:
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 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.
curl https://backtalk.sh/api/v1/me -H "Authorization: Bearer btk_YOUR_KEY"
{
"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) |
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"
{
"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):
{ "state": "ignored", "ignore_reason_chip": "job_ad", "ignore_reason_text": "recruiter post" }
state:new,seen,replied, orignored. (spamis 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 whenstateisignored.ignore_reason_text: free text (≤ 500 chars).
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).
curl -X PATCH https://backtalk.sh/api/v1/signals \
-H "Authorization: Bearer btk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"ids": ["…", "…"], "state": "seen"}'
{ "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.
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.
curl "https://backtalk.sh/api/v1/ai/share-of-voice?window=7" -H "Authorization: Bearer btk_YOUR_KEY"
{
"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 thestarredfilter onGET /ai/answersreads).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.
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.
curl https://backtalk.sh/api/v1/ai/probe -H "Authorization: Bearer btk_YOUR_KEY"
{ "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.
curl -X POST https://backtalk.sh/api/v1/ai/probe -H "Authorization: Bearer btk_YOUR_KEY"
{ "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.
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) |
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:
{ "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_….