API reference
Every REST endpoint, parameter and schema, generated from the OpenAPI document.
This reference is generated from the same OpenAPI document served at https://signalwatchr-dev.vercel.app/api/public/v1/openapi.json, so it always matches the live API. For authentication, pagination, errors and rate limits, see the REST API overview.
Base URL: https://signalwatchr-dev.vercel.app/api/public/v1
Account
get/me
Plan, billing state, limits, usage and credits. Required scope: account:read.
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Account |
Rules
get/rules
List rules. Required scope: rules:read.
Parameters
| Name | Type | Description |
|---|---|---|
| status (query) | "active" | "paused" | "disabled" | |
| q (query) | string | |
| limit (query) | integer | Page size (1-100, default 25) Default: 25 |
| cursor (query) | string | Cursor from the previous page's nextCursor |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Rule[] | |
| nextCursor* | string | null | Pass as `cursor` to get the next page; null = last page |
post/rules
Create a rule. Keyword and page limits of your plan apply (same rules as the app): only active pages and keywords of active rules count. Keywords count once across all rules, each page once, and each keyword searched on a platform counts as a page — once per country in search_geos (empty = anywhere = once). search_geos must be codes from GET /search-countries (400 otherwise). Facebook is a keyword-search platform too. inactive_page_ids / inactive_keywords stay on the rule without counting. Over the limit: 403 plan_limit_reached. Required scope: rules:write.
Parameters
| Name | Type | Description |
|---|---|---|
| Idempotency-Key (header) | string | Retry-safe key (≤255 chars). Same key + same body replays the stored response for 24h; a different body returns 409 idempotency_conflict. |
Request body
| Name | Type | Description |
|---|---|---|
| name | string | null | Display name |
| prompt_text* | string | What a matching post looks like, in plain language. The AI scores posts against it. |
| threshold | number | Minimum AI relevance score (0-1) for a match. Default 0.7. Default: 0.7 |
| page_ids | string[] | Ids of your pages (see list_pages / GET /pages) to monitor Default: [] |
| inactive_page_ids | string[] | Pages kept on the rule but turned off: not monitored and not counted toward plan usage. Omit to keep the current ones. |
| alert_channel_ids | string[] | Alert channel ids that receive matches Default: [] |
| keywords | string[] | Keywords to search for. Monitored on the platforms in search_keywords_sources. Default: [] |
| inactive_keywords | string[] | Keywords kept on the rule but turned off: not searched or matched and not counted toward plan usage. Omit to keep the current ones. |
| custom_tags | string[] | Default: [] |
| sentiment_analysis | object | Default: {"enable":false} |
| search_keywords_sources | string[] | Platforms searched for the keywords: facebook, reddit, x, linkedin, tiktok, youtube. |
| search_geos | string[] | ISO-2 countries the keyword searches run from. Empty = anywhere. Must be one of GET /api/public/v1/search-countries. Each country is a separate search and counts as one page unit per keyword and platform. |
| status | "active" | "paused" | Default: "active" |
| fb_group_ids | string[] | Deprecated: use page_ids. Facebook group ids; missing ones are added as pages. |
| subreddit_ids | string[] | Deprecated: use page_ids. Subreddit names; missing ones are added as pages. |
Response 201
| Name | Type | Description |
|---|---|---|
| data* | Rule |
get/rules/{id}
Get a rule. Required scope: rules:read.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Rule |
patch/rules/{id}
Update a rule (partial). Omitted fields keep their current values. Turn pages or keywords off/on with inactive_page_ids / inactive_keywords (alone, they keep the rule's sources and only change which are off). Keyword and page limits are checked on every update; a change that doesn't raise usage is always allowed. Over the limit: 403 plan_limit_reached. Required scope: rules:write.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string |
Request body
| Name | Type | Description |
|---|---|---|
| name | string | null | |
| prompt_text | string | |
| threshold | number | |
| page_ids | string[] | Active pages. Pages turned off stay in inactive_page_ids unless listed here. |
| inactive_page_ids | string[] | Pages kept on the rule but turned off: not monitored and not counted toward plan usage. Omit to keep the current ones. |
| fb_group_ids | string[] | Deprecated: use page_ids. Replaces the rule's Facebook groups. |
| subreddit_ids | string[] | Deprecated: use page_ids. Replaces the rule's subreddits. |
| alert_channel_ids | string[] | |
| keywords | string[] | Keywords to search for. Monitored on the platforms in search_keywords_sources. |
| inactive_keywords | string[] | Keywords kept on the rule but turned off: not searched or matched and not counted toward plan usage. Omit to keep the current ones. |
| custom_tags | string[] | |
| sentiment_analysis | object | |
| search_keywords_sources | string[] | Platforms searched for the keywords: facebook, reddit, x, linkedin, tiktok, youtube. |
| search_geos | string[] | ISO-2 countries the keyword searches run from. Empty = anywhere. Must be one of GET /api/public/v1/search-countries. Each country is a separate search and counts as one page unit per keyword and platform. |
| status | "active" | "paused" |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Rule |
delete/rules/{id}
Delete a rule. Required scope: rules:write.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string |
Response 204
No content.
post/rules/{id}/pause
Pause a rule. A paused rule's pages and keywords stop counting toward plan usage. Required scope: rules:write.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string | |
| Idempotency-Key (header) | string | Retry-safe key (≤255 chars). Same key + same body replays the stored response for 24h; a different body returns 409 idempotency_conflict. |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Rule |
post/rules/{id}/resume
Resume a paused rule. The rule's active pages and keywords count toward plan usage again. Over the limit: 403 plan_limit_reached. Required scope: rules:write.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string | |
| Idempotency-Key (header) | string | Retry-safe key (≤255 chars). Same key + same body replays the stored response for 24h; a different body returns 409 idempotency_conflict. |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Rule |
get/search-countries
Countries keyword searches can run from. ISO-2 codes allowed in a rule's search_geos. Each country is a separate search and counts as one page per keyword and platform. An empty list means only "anywhere" searches are available (leave search_geos empty). Required scope: rules:read.
Response 200
| Name | Type | Description |
|---|---|---|
| data* | SearchCountries |
Pages
get/pages
List monitored pages. Facebook groups, subreddits, review sites, app stores and web pages you monitor. Use their ids in a rule's page_ids. Required scope: pages:read.
Parameters
| Name | Type | Description |
|---|---|---|
| q (query) | string | Search name, URL or id |
| kind (query) | "fb_group" | "subreddit" | "web" | |
| review_status (query) | "accepted" | "pending" | "rejected" | |
| limit (query) | integer | Page size (1-100, default 25) Default: 25 |
| cursor (query) | string | Cursor from the previous page's nextCursor |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Page[] | |
| nextCursor* | string | null | Pass as `cursor` to get the next page; null = last page |
post/pages
Add a page by URL. Facebook group, subreddit, or any other page. Sites not in the accepted list are reviewed first (review_status pending). Returns the existing page if it's already listed. Invalid links return 422 invalid_url with details.errors[{url, code, message}]. Each page counts toward your plan. Required scope: pages:write.
Parameters
| Name | Type | Description |
|---|---|---|
| Idempotency-Key (header) | string | Retry-safe key (≤255 chars). Same key + same body replays the stored response for 24h; a different body returns 409 idempotency_conflict. |
Request body
| Name | Type | Description |
|---|---|---|
| url* | string | Link to a Facebook group, subreddit, review/app store page, blog or forum |
Response 201
| Name | Type | Description |
|---|---|---|
| data* | Page |
delete/pages/{id}
Remove a page. Rules stop monitoring it. Required scope: pages:write.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string |
Response 204
No content.
Channels
get/channels
List alert channels. Required scope: channels:read.
Parameters
| Name | Type | Description |
|---|---|---|
| type (query) | "email" | "slack" | "telegram" | "webhook" | |
| status (query) | "active" | "disabled" | |
| limit (query) | integer | Page size (1-100, default 25) Default: 25 |
| cursor (query) | string | Cursor from the previous page's nextCursor |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Channel[] | |
| nextCursor* | string | null | Pass as `cursor` to get the next page; null = last page |
post/channels
Create an alert channel. Types: email, slack, telegram, webhook. email sends a verification link. slack: pass an incoming-webhook URL, or omit destination to get setup.oauth_url (open it in a browser to Add to Slack). telegram returns setup.telegram.deep_link; open it and press Start to pair a chat. webhook: optional webhook_secret signs each request. Required scope: channels:write.
Parameters
| Name | Type | Description |
|---|---|---|
| Idempotency-Key (header) | string | Retry-safe key (≤255 chars). Same key + same body replays the stored response for 24h; a different body returns 409 idempotency_conflict. |
Request body
| Name | Type | Description |
|---|---|---|
| type* | "email" | "slack" | "telegram" | "webhook" | Channel type. email sends a verification link. slack: pass an incoming-webhook URL, or leave destination empty to get setup.oauth_url (Add to Slack). telegram returns setup.telegram.deep_link to pair a chat. |
| destination | string | email: the address. webhook: a public HTTPS URL. slack: an incoming-webhook URL (https://hooks.slack.com/...) or empty for Add to Slack. telegram: leave empty. Default: "" |
| settings | object | email: { send_interval_minutes } (minimum minutes between emails, default 60). slack: { channel_name } (label). Other keys are ignored. Default: {} |
| status | "active" | "disabled" | Default: "active" |
| webhook_secret | string | Webhook only: signing secret (write-only). Requests then carry X-App-Signature: sha256=<HMAC of the body>. |
Response 201
| Name | Type | Description |
|---|---|---|
| data* | Channel | |
| setup* | object |
get/channels/{id}
Get an alert channel. Required scope: channels:read.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string |
Response 200
| Name | Type | Description |
|---|---|---|
| id* | string | |
| type* | string | |
| destination* | string | Email address, or a masked URL/identifier for other types |
| status* | "active" | "disabled" | |
| verification_status* | "pending" | "verified" | "failed" | null | |
| is_verified* | boolean | |
| has_webhook_secret* | boolean | |
| slack_channel_id* | string | null | |
| slack_connected* | boolean | Slack: connected with Add to Slack |
| telegram_paired* | boolean | |
| last_verified_at* | string | null | |
| last_test_sent_at* | string | null | |
| created_at* | string | null | |
| updated_at* | string | null |
patch/channels/{id}
Update an alert channel. Changing the destination restarts verification. webhook_secret sets a new signing secret ("" removes it). Required scope: channels:write.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string |
Request body
| Name | Type | Description |
|---|---|---|
| destination | string | |
| settings | object | |
| status | "active" | "disabled" | |
| webhook_secret | string | Webhook only: new signing secret; "" removes it. |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Channel | |
| setup* | object |
delete/channels/{id}
Delete an alert channel. Fails while the channel is linked to an active rule. Required scope: channels:write.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string |
Response 204
No content.
post/channels/{id}/test
Send a test alert (or finish setup). Sends a sample alert. If the channel can't receive one yet, result has the next setup step instead: a resent email link, oauth_url for Slack, or telegram.deep_link. Required scope: channels:write.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string | |
| Idempotency-Key (header) | string | Retry-safe key (≤255 chars). Same key + same body replays the stored response for 24h; a different body returns 409 idempotency_conflict. |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Channel | null | |
| result* | object |
Matches
get/matches
Search matched posts (newest first). Required scope: feed:read.
Parameters
| Name | Type | Description |
|---|---|---|
| rule (query) | string | Comma-separated rule ids |
| source (query) | string | Platform ("facebook", "reddit", "x", a site id like "trustpilot"), a category ("review_site", "app_store"), "page" for every page source, or a source ref like "reddit:subreddit:abc" |
| keyword (query) | string | Exact matched keyword (case-insensitive) |
| sentiment (query) | string | Comma-separated sentiments: positive, neutral, negative |
| tags (query) | string | Comma-separated tags: buy_intent, recommendation_request, alternative_seeking, complaint, praise, question, feedback, other |
| from (query) | string | Start day (UTC), YYYY-MM-DD, inclusive |
| to (query) | string | End day (UTC), YYYY-MM-DD, inclusive |
| limit (query) | integer | Page size (1-100, default 25) Default: 25 |
| cursor (query) | string | Cursor from the previous page's nextCursor |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Match[] | |
| nextCursor* | string | null | Pass as `cursor` to get the next page; null = last page |
get/matches/{id}
Get a match with the full post text. Required scope: feed:read.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | Match |
post/matches/{id}/comment-suggestions
Generate a comment suggestion (uses 1 credit). Required scope: comments:write.
Parameters
| Name | Type | Description |
|---|---|---|
| id (path)* | string | |
| Idempotency-Key (header) | string | Retry-safe key (≤255 chars). Same key + same body replays the stored response for 24h; a different body returns 409 idempotency_conflict. |
Request body
| Name | Type | Description |
|---|---|---|
| context | string | What the comment should achieve, e.g. "Offer help and mention our free plan". Defaults to your default purpose in the app. |
Response 201
| Name | Type | Description |
|---|---|---|
| data* | CommentSuggestion |
Stats
get/stats/summary
Dashboard summary: matches, filtering, usage, credits. Required scope: stats:read.
Parameters
| Name | Type | Description |
|---|---|---|
| range (query) | "7d" | "30d" | "90d" | "custom" | Default: "7d" |
| from (query) | string | |
| to (query) | string |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | object | Same numbers as the app's Dashboard / Statistics |
get/stats/timeseries
Matches per day with an optional breakdown. Required scope: stats:read.
Parameters
| Name | Type | Description |
|---|---|---|
| range (query) | "7d" | "30d" | "90d" | "custom" | Default: "7d" |
| from (query) | string | |
| to (query) | string | |
| rules (query) | string | |
| breakdown (query) | "none" | "platform" | "rule" | "result" | "sentiment" | "tag" | Default: "platform" |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | object | Same numbers as the app's Dashboard / Statistics |
get/stats/sources
Per-source statistics. Required scope: stats:read.
Parameters
| Name | Type | Description |
|---|---|---|
| range (query) | "7d" | "30d" | "90d" | "custom" | Default: "7d" |
| from (query) | string | |
| to (query) | string | |
| rules (query) | string | |
| search (query) | string | Default: "" |
| sort (query) | "name" | "postsCollected" | "keywordHits" | "matched" | "matchRate" | "lastMatchAt" | "rulesCount" | Default: "matched" |
| order (query) | "asc" | "desc" | Default: "desc" |
| page (query) | integer | Default: 1 |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | object | Same numbers as the app's Dashboard / Statistics |
get/stats/keywords
Per-keyword statistics. Required scope: stats:read.
Parameters
| Name | Type | Description |
|---|---|---|
| range (query) | "7d" | "30d" | "90d" | "custom" | Default: "7d" |
| from (query) | string | |
| to (query) | string | |
| rules (query) | string | |
| search (query) | string | Default: "" |
| sort (query) | "keyword" | "keywordHits" | "matched" | "matchRate" | "lastMatchAt" | Default: "matched" |
| order (query) | "asc" | "desc" | Default: "desc" |
| page (query) | integer | Default: 1 |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | object | Same numbers as the app's Dashboard / Statistics |
get/stats/rules
Per-rule statistics. Required scope: stats:read.
Parameters
| Name | Type | Description |
|---|---|---|
| range (query) | "7d" | "30d" | "90d" | "custom" | Default: "7d" |
| from (query) | string | |
| to (query) | string | |
| rules (query) | string | |
| search (query) | string | Default: "" |
| sort (query) | "name" | "matched" | "matchRate" | "alertsSent" | "commentSuggestions" | "lastMatchAt" | Default: "matched" |
| order (query) | "asc" | "desc" | Default: "desc" |
| page (query) | integer | Default: 1 |
Response 200
| Name | Type | Description |
|---|---|---|
| data* | object | Same numbers as the app's Dashboard / Statistics |
Schemas
Error
| Name | Type | Description |
|---|---|---|
| error* | object |
Account
| Name | Type | Description |
|---|---|---|
| plan* | "pro" | "max" | "enterprise" | null | |
| interval* | "monthly" | "yearly" | null | |
| billing_state* | string | |
| trial_ends_at* | string | null | |
| current_period_end* | string | null | |
| cancel_at_period_end* | boolean | |
| limits* | object | Plan limits (Enterprise: custom limits) |
| usage* | object | |
| over_limit* | boolean | |
| credits* | object | |
| api* | object |
RuleList
| Name | Type | Description |
|---|---|---|
| data* | Rule[] | |
| nextCursor* | string | null | Pass as `cursor` to get the next page; null = last page |
Rule
| Name | Type | Description |
|---|---|---|
| id* | string | |
| name* | string | null | |
| prompt_text* | string | |
| threshold* | number | |
| status* | "active" | "paused" | "disabled" | |
| keywords* | string[] | Active keywords |
| inactive_keywords* | string[] | Keywords kept on the rule but turned off: not searched or matched, and not counted toward plan usage |
| search_keywords_sources* | string[] | |
| search_geos* | string[] | ISO-2 countries the keyword searches run from (empty = anywhere). Countries no longer offered stay listed but aren't searched. |
| page_ids* | string[] | Ids of the active pages this rule monitors (see /pages) |
| inactive_page_ids* | string[] | Pages kept on the rule but turned off: not monitored, and not counted toward plan usage |
| fb_group_ids* | string[] | Deprecated: derived from page_ids (Facebook group ids) |
| subreddit_ids* | string[] | Deprecated: derived from page_ids (subreddit names) |
| alert_channel_ids* | string[] | |
| custom_tags* | string[] | |
| sentiment_analysis* | object | |
| created_at* | string | null | |
| updated_at* | string | null |
SearchCountries
| Name | Type | Description |
|---|---|---|
| countries* | string[] | ISO-2 codes usable in a rule's search_geos. Empty = searching from specific countries isn't available. |
PageList
| Name | Type | Description |
|---|---|---|
| data* | Page[] | |
| nextCursor* | string | null | Pass as `cursor` to get the next page; null = last page |
Page
| Name | Type | Description |
|---|---|---|
| id* | string | |
| kind* | "fb_group" | "subreddit" | "web" | |
| platform* | string | facebook, reddit, a site id (trustpilot, g2, capterra, apple_app_store, google_play) or web |
| source_type* | "group" | "subreddit" | "page" | |
| source_id* | string | Shared crawl key: FB group id, subreddit name, or w_<hash> |
| url* | string | |
| domain* | string | |
| name* | string | null | |
| category* | "community" | "review_site" | "app_store" | "blog" | "forum" | "web" | |
| review_status* | "accepted" | "pending" | "rejected" | pending = new site waiting for review; starts once accepted |
| review_note* | string | null | |
| status* | "active" | "starting" | "awaiting_access" | "in_review" | "rejected" | "no_access" | Monitoring status |
| last_scanned_at* | string | null | |
| created_at* | string | null | |
| updated_at* | string | null |
ChannelList
| Name | Type | Description |
|---|---|---|
| data* | Channel[] | |
| nextCursor* | string | null | Pass as `cursor` to get the next page; null = last page |
Channel
| Name | Type | Description |
|---|---|---|
| id* | string | |
| type* | string | |
| destination* | string | Email address, or a masked URL/identifier for other types |
| status* | "active" | "disabled" | |
| verification_status* | "pending" | "verified" | "failed" | null | |
| is_verified* | boolean | |
| has_webhook_secret* | boolean | |
| slack_channel_id* | string | null | |
| slack_connected* | boolean | Slack: connected with Add to Slack |
| telegram_paired* | boolean | |
| last_verified_at* | string | null | |
| last_test_sent_at* | string | null | |
| created_at* | string | null | |
| updated_at* | string | null |
MatchList
| Name | Type | Description |
|---|---|---|
| data* | Match[] | |
| nextCursor* | string | null | Pass as `cursor` to get the next page; null = last page |
Match
| Name | Type | Description |
|---|---|---|
| id* | string | Match id (use for get_match / comment suggestions) |
| post_id* | string | |
| rule_id* | string | |
| rule_prompt* | string | null | |
| platform* | string | |
| url* | string | null | Link to the full post |
| author* | string | null | |
| text* | string | |
| text_truncated* | boolean | |
| score* | number | null | AI relevance score 0-1 |
| reason* | string | null | Why the AI matched it |
| sentiment* | string | null | |
| tags* | string[] | |
| matched_keywords* | string[] | |
| status* | string | null | |
| matched_at* | string | null | |
| posted_at* | string | null | |
| feedback* | "helpful" | "not_helpful" | null |
CommentSuggestion
| Name | Type | Description |
|---|---|---|
| match_id* | string | |
| post_id* | string | |
| text* | string | |
| language* | string | null | |
| purpose* | string | null | |
| generation_count* | number | |
| credits* | object |