> **For coding agents and LLMs:** This is one page from the Social Fetch docs (markdown export). For curated orientation and workflow guidance, start with [`/llms.txt`](https://www.socialfetch.dev/llms.txt); for agent onboarding and crawl rules, use [`/agents.txt`](https://www.socialfetch.dev/agents.txt); for the full endpoint list with links to pages like this one, use [`/llms-endpoints.txt`](https://www.socialfetch.dev/llms-endpoints.txt); for one platform's parameters and curls, use [`/llms-{platform}.txt`](https://www.socialfetch.dev/llms-tiktok.txt); use [`/llms.json`](https://www.socialfetch.dev/llms.json) when you need structured JSON for tool registration.

## This page

- **On-site (HTML):** <https://www.socialfetch.dev/docs/recipes/saved-queries>
- **Markdown (.mdx) URL:** <https://www.socialfetch.dev/docs/recipes/saved-queries.mdx>

## API base URL and authentication

- **API origin (from OpenAPI `servers`):** `https://api.socialfetch.dev`
- **Authentication:** send `x-api-key: sfk_...` on `/v1/**` and `/v2/**` routes unless the operation is explicitly anonymous (check OpenAPI `security`, the [API reference hub](https://www.socialfetch.dev/docs/api.mdx), [`/llms.txt`](https://www.socialfetch.dev/llms.txt), or [`/llms.json`](https://www.socialfetch.dev/llms.json) for each route).
- **OpenAPI JSON:** <https://www.socialfetch.dev/openapi.json>

## Recommended docs entrypoints (this site)

- [Documentation overview](https://www.socialfetch.dev/docs.mdx) — top-level orientation (markdown).
- [Quickstart](https://www.socialfetch.dev/docs/quickstart.mdx) — authenticate with `x-api-key`, validate auth with `whoami`, and understand the JSON envelope.
- [SDK](https://www.socialfetch.dev/docs/sdk.mdx) — official TypeScript SDK guide, including `SocialFetchClient`, `Result`, and `unwrap()`.
- [Capability matrix](https://www.socialfetch.dev/docs/capability-matrix.mdx) — every operation with identifiers, pagination, outcomes, media download, credits, and its SDK method. Generated from OpenAPI, so use it for route selection instead of scanning individual pages.
- [Recipes](https://www.socialfetch.dev/docs/recipes.mdx) — copyable workflows (brand monitoring, transcripts, Ad Library, creator scoring, Reddit research) with credit callouts and SDK examples.
- [Integrations](https://www.socialfetch.dev/docs/integrations.mdx) — MCP for AI clients, n8n verified node, Apify Store Actors, Make custom app, SDK, and REST API connection paths.
- [MCP product page](https://www.socialfetch.dev/mcp) — hosted MCP overview, OAuth, Skills install.
- [MCP integration](https://www.socialfetch.dev/docs/integrations/mcp.mdx) — hosted `/mcp` server, OAuth, Cursor/VS Code/Claude install snippets, 910 endpoint tools, plus docs_search/docs_read for implementation help.
- [n8n integration](https://www.socialfetch.dev/docs/integrations/n8n.mdx) — install `n8n-nodes-socialfetch`, credentials, and workflow examples.
- [Apify integration](https://www.socialfetch.dev/docs/integrations/apify.mdx) — Store Actors under @social-fetch, PPE billing, dataset export, and quick start.
- [Make integration](https://www.socialfetch.dev/docs/integrations/make.mdx) — custom app modules for Make scenarios, API key credentials, and module catalog.
- [`/llms-endpoints.txt`](https://www.socialfetch.dev/llms-endpoints.txt) — every documented operation with a direct link to that route's agent-readable markdown page (prefer this over parsing OpenAPI).
- [`/llms-{platform}.txt`](https://www.socialfetch.dev/llms-tiktok.txt) — per-platform endpoint files generated from OpenAPI (parameters, credits, curls).
- [`/agents.txt`](https://www.socialfetch.dev/agents.txt) — agent crawl/onboarding file with capabilities, auth rules, and allowlist.
- [`/llms.json`](https://www.socialfetch.dev/llms.json) — structured machine-readable operation inventory with parameter names, pagination, outcomes, credits, and SDK mapping.
- [API reference hub](https://www.socialfetch.dev/docs/api.mdx) — human-friendly index of operations with links into generated pages.
- [Errors](https://www.socialfetch.dev/docs/errors.mdx) — shared error envelope and HTTP status guidance.
- [Credits](https://www.socialfetch.dev/docs/credits.mdx) — metering, `402`, and planning batch jobs.
- Outcome semantics such as `found`, `not_found`, and `private` are documented in [Errors](https://www.socialfetch.dev/docs/errors.mdx) and on operation pages when present in the OpenAPI contract.

## Markdown docs convention

- Every docs page has a markdown twin: append **`.mdx`** to the docs pathname (for example `/docs/quickstart` → `/docs/quickstart.mdx`).
- Agents that send `Accept: text/markdown` on `/docs/**` HTML URLs may receive markdown directly (same URL, `Vary: Accept`).
- Published blog posts use the same convention: `/blog/{slug}` → `/blog/{slug}.mdx`, or `Accept: text/markdown` on the HTML URL (`Vary: Accept`).

---
# Run a Boolean query on a schedule (https://www.socialfetch.dev/docs/recipes/saved-queries)

{/* Shapes come from packages/public-api-contract/src/endpoints/saved-queries-*.ts; the webhook event from packages/saved-queries/src/service.ts (recordRun, saved_query.matches, saved_query.spike) and thresholds.ts (saved_query.threshold), and docs/plans/query-engine/README.md Q43–Q44, Q49, Q50; the numbers from packages/saved-queries/src/stats.ts and Q51. Briefings: packages/saved-queries/src/briefings.ts (saved_query.briefing), Q52. Insights: packages/saved-queries/src/insights.ts, docs/plans/query-engine/insights.md, Q62. */}

A saved query is a Boolean query that Social Fetch searches for you on a schedule. Each run searches the platforms you chose, keeps every post the query matches (each post once, however many runs find it), and can send the new ones to your webhook endpoint. It's the scheduled form of [`POST /v1/query/search`](/docs/api/v1/query/search/post).

## What you'll need

* An [API key](https://app.socialfetch.dev/api-keys).
* A query that finds what you mean. Check it for free with [`POST /v1/query/validate`](/docs/api/v1/query/validate/post), or [write one from a description](/docs/recipes/write-a-query-from-a-description).
* Optionally, a [webhook endpoint](/docs/monitors/webhooks) for new matches.

## What it costs

Managing saved queries is free. Each run is one search and is charged like `POST /v1/query/search`: it holds up to the query's `maxCreditsPerRun` while it runs and gives back what it doesn't use. Set `monthlyCreditCap` to cap what one query may spend in a calendar month (UTC): a run that could pass the cap doesn't start, the query shows `capped`, and it starts again on the first of the next month or when you raise the cap.

## Step 1: Save the query

```typescript
const response = await fetch("https://api.socialfetch.dev/v1/saved-queries", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-api-key": process.env.SOCIALFETCH_API_KEY!,
  },
  body: JSON.stringify({
    name: "Lumora Foods complaints",
    query: '("Lumora Foods" OR #LumoraFoods) AND (recall* OR lawsuit* OR "food poisoning")',
    platforms: ["twitter", "reddit", "youtube"],
    maxCreditsPerRun: 20,
    cadenceMinutes: 360,
    monthlyCreditCap: 500,
    webhookEndpointId: "we_…",
    alertThreshold: { posts: 50, window: "hour" },
  }),
});
const { data: savedQuery } = await response.json();
```

The query is read before it's saved: one that can't be read, or that nothing can search on the platforms you chose, is refused with the reason. Leave out `platforms` to search every platform search covers. `cadenceMinutes` runs from 15 to 10,080 (a week), and a workspace can have 100 saved queries. It runs once straight away unless you send `runNow: false`.

A workspace with zero retention can't save a query, because a saved query keeps the posts it matches.

## Step 2: Read its matches

```typescript
const page = await fetch(
  `https://api.socialfetch.dev/v1/saved-queries/${savedQuery.id}/matches?limit=50`,
  { headers: { "x-api-key": process.env.SOCIALFETCH_API_KEY! } },
).then((r) => r.json());

for (const match of page.data.matches) {
  console.log(match.mention.platform, match.mention.url, match.mention.text);
}
// More? Pass page.data.page.nextCursor as `cursor`.
```

Matches come newest first. Filter with `platform`, `since` and `until`. Each `mention` has the same shape as a search's, and a match also says when it was first and last seen and which run found it. Matches are kept for a year.

## Step 3: Get new matches as webhooks

With a `webhookEndpointId`, every run that stores new matches sends one `saved_query.matches` event to that endpoint. It's signed, retried and logged like [monitor events](/docs/monitors/delivery-retries); dedupe on `socialfetch-event-id`.

```json
{
  "id": "evt_…",
  "apiVersion": "2026-08-01",
  "type": "saved_query.matches",
  "createdAt": "2026-11-07T18:00:41.000Z",
  "data": {
    "summary": "\"Lumora Foods complaints\" matched 3 new posts",
    "url": "https://app.socialfetch.dev/tools/listening/saved/sq_…",
    "savedQuery": { "id": "sq_…", "name": "Lumora Foods complaints" },
    "run": { "id": "sqr_…", "creditsCharged": 9 },
    "count": 3,
    "attention": { "needsReply": 1, "risk": 0 },
    "matches": [{ "key": "twitter:1854…", "platform": "twitter", "url": "https://x.com/…", "text": "…" }],
    "truncated": false
  }
}
```

`matches` lists up to 25 of the new posts, newest first, in the search's mention shape (shortened above); `count` is how many there were, and `truncated` says whether the list is shorter. Read the rest from the matches route. Each match's `duplicateOf` names the post it repeats (a repost or a near-copy) and why, or is null: the post it names can be from an earlier run or one past the 25 listed, so look it up rather than expect it in `matches`. `attention` counts the new posts [insights](#step-8-let-it-read-every-post) flag as needing a reply or as a risk (zeros without insights). A run that finds nothing new sends nothing, and an endpoint that's disabled gets nothing.

## Step 4: Hear about spikes

A spike is a day far louder than the query's usual one. Each run counts the posts the query has stored that were posted in the 24 hours before it, and compares that with each of the 14 full days (UTC) before today. It's a spike when all three hold:

* at least 20 posts in the 24 hours,
* at least the daily average plus three standard deviations (counting a deviation under 1 as 1),
* at least twice the daily average.

Only posts with a post time count. A query has no spikes in its first 7 days, or for 7 days after its query changes, so history found by its first runs or by the old wording doesn't look like one. After a gap (paused, capped, or a failed run) the next run doesn't look for one either, as its catch-up would fill the 24 hours. A query has at most one spike a day.

A spike is shown on the query in the Listening app for a day. With a `webhookEndpointId`, it's also sent as one `saved_query.spike` event, signed, retried and logged like `saved_query.matches`:

```json
{
  "id": "evt_…",
  "apiVersion": "2026-08-01",
  "type": "saved_query.spike",
  "createdAt": "2026-11-07T18:00:41.000Z",
  "data": {
    "summary": "\"Lumora Foods complaints\" has 64 posts in 24 hours, usually about 12 a day",
    "url": "https://app.socialfetch.dev/tools/listening/saved/sq_…",
    "savedQuery": { "id": "sq_…", "name": "Lumora Foods complaints" },
    "count24h": 64,
    "baseline": 12,
    "window": { "from": "2026-11-06T18:00:41.000Z", "to": "2026-11-07T18:00:41.000Z" },
    "topMatches": [{ "key": "twitter:1854…", "platform": "twitter", "url": "https://x.com/…", "text": "…" }]
  }
}
```

`count24h` is the posts in `window`, `baseline` the average day, rounded, and `topMatches` up to 5 of the newest posts in the window, in the search's mention shape. A run that finds a spike sends its `saved_query.matches` event too, as a separate event.

## Step 5: Read the numbers

Count what a query has stored, for free, with [`GET /v1/saved-queries/{id}/stats`](/docs/api/v1/saved-queries/id/stats/get). Nothing is searched, so it costs no credits.

```typescript
const stats = await fetch(
  `https://api.socialfetch.dev/v1/saved-queries/${savedQuery.id}/stats?days=30`,
  { headers: { "x-api-key": process.env.SOCIALFETCH_API_KEY! } },
).then((r) => r.json());

for (const day of stats.data.daily) console.log(day.day, day.posts, day.stories);
console.log(stats.data.topAuthors, stats.data.topSites);
```

The window is whole days in UTC, today included: `days` from 1 to 90 (30 by default). Send `now` to fix the window, so reading it again gives the same days.

* **`daily`** has every day in the window, oldest first, with zeros for quiet days. A post is on the day it was posted, and each day splits its posts by platform. A post the platform gave no time for is on no day.
* **`platforms`** counts each platform's posts first found in the window, with or without a time, most first. `totals` is their sum.
* **`topAuthors`** and **`topSites`** list the 10 accounts and the 10 linked sites heard from most. A site is the host a post's http or https link points to, without `www.`, a port or a trailing dot (an IP address isn't a site); a post linking to a site twice counts once, and a shortened link counts as the shortener's site. An international name counts as written, so its own letters and its `xn--` form count apart.

Every count comes as `posts` and `stories`. A post and its reposts and near-copies (`duplicateOf`) are one story, even when the post they repeat was posted before the window, so `stories` counts each thing that happened once. A story on several days or platforms counts once on each, so days' stories can add up to more than the window's.

To compare several queries, such as your brand against two competitors, send 2 to 10 of them to [`GET /v1/saved-queries/stats/compare`](/docs/api/v1/saved-queries/stats/compare/get):

```typescript
const ids = [ours.id, rivalA.id, rivalB.id].join(",");
const comparison = await fetch(
  `https://api.socialfetch.dev/v1/saved-queries/stats/compare?ids=${ids}&days=30`,
  { headers: { "x-api-key": process.env.SOCIALFETCH_API_KEY! } },
).then((r) => r.json());

for (const q of comparison.data.queries)
  console.log(q.name, q.posts, `${Math.round(q.share * 100)}%`);
```

Each query's `share` is its posts over the posts of all the queries compared, overall and on each day in `daily`; while any query has posts, the shares add up to 1 (to within rounding), and they're all 0 when none has. Only posts with a post time count here: a query's `posts` is the sum of `posts` over its stats' `daily`, not its `totals`, which count by when posts were found. A post two of the queries matched counts for both.

## Step 6: Set a volume alert

A spike compares a day with the query's usual one. A volume alert is a number you choose: "alert me when it finds 50 or more posts in an hour". Set `alertThreshold` when you save the query, or later with [`PATCH /v1/saved-queries/{id}`](/docs/api/v1/saved-queries/id/patch); send `null` to remove it.

```json
{ "alertThreshold": { "posts": 50, "window": "hour" } }
```

`posts` is a whole number from 1 to 1,000,000, and `window` is `hour` or `day`. The query must run at least once a window: an `hour` alert needs a `cadenceMinutes` of 60 or less, and a `day` alert 1,440 or less; a pair that doesn't fit is refused, whichever of the two you change. Each run counts the posts the query found in the hour or day before it, by when it found them, its own new ones included. A post found again counts once, and a post with no post time counts too. When the count reaches `posts`, the run sends an alert, at most one an hour (or a day). Only a run that succeeded or was partial sends one. The first run of a query, and the first after its query changes, never sends one, and what they found doesn't count later: they find the backlog, which would look like a burst. Nor does a run catching up after a gap (paused, capped or failing), when the last run that stored anything finished more than two cadences or a window ago, whichever is longer; its posts still count toward the next runs' windows. Changing the threshold starts afresh.

An alert is shown on the query in the Listening app for its hour or day. With a `webhookEndpointId`, it's also sent as one `saved_query.threshold` event, signed, retried and logged like `saved_query.matches`:

```json
{
  "id": "evt_…",
  "apiVersion": "2026-08-01",
  "type": "saved_query.threshold",
  "createdAt": "2026-11-07T18:00:41.000Z",
  "data": {
    "summary": "\"Lumora Foods complaints\" found 63 posts in the last hour (alert at 50)",
    "url": "https://app.socialfetch.dev/tools/listening/saved/sq_…",
    "savedQuery": { "id": "sq_…", "name": "Lumora Foods complaints" },
    "threshold": { "posts": 50, "window": "hour" },
    "count": 63,
    "window": { "from": "2026-11-07T17:00:41.000Z", "to": "2026-11-07T18:00:41.000Z" },
    "topMatches": [{ "key": "twitter:1854…", "platform": "twitter", "url": "https://x.com/…", "text": "…" }]
  }
}
```

`count` is the posts found in `window`, and `topMatches` up to 5 of the newest of them, in the search's mention shape. The run's `saved_query.matches` event (and a spike's, if it finds one) is sent separately.

## Step 7: Get a daily or weekly briefing

Set `briefing` to `daily` or `weekly` when you create or update a query (`null`, the default, is none):

```bash
curl -X PATCH https://api.socialfetch.dev/v1/saved-queries/sq_… \
  -H "x-api-key: $SOCIALFETCH_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "briefing": "weekly" }'
```

A daily briefing covers the day before (midnight to midnight, UTC) and comes at 08:00 UTC; a weekly one covers Monday to Sunday and comes on Monday at 08:00 UTC. It counts the posts the query has stored that were posted in that period, and says:

* how many there were, and the change on the period before (from the query's second period on),
* how many stories: reposts and near-copies of a post in the same period count once,
* how many on each platform, and the 5 accounts with the most posts,
* the 5 most engaged posts (likes, comments and shares added up, a missing count as 0), one a story,
* the spikes in the period, if it had any.

A briefing is written only for an active query that ran in the period without failing, once a period. It's free. It's shown on the query in the Listening app and, with a `webhookEndpointId`, sent as one `saved_query.briefing` event, signed, retried and logged like the others:

```json
{
  "id": "evt_…",
  "apiVersion": "2026-08-01",
  "type": "saved_query.briefing",
  "createdAt": "2026-11-09T08:05:02.000Z",
  "data": {
    "summary": "\"Lumora Foods complaints\" briefing: 142 posts in the week of 2 Nov, up 38% on the week before; most on TikTok",
    "headline": "142 posts in the week of 2 Nov, up 38% on the week before; most on TikTok",
    "url": "https://app.socialfetch.dev/tools/listening/saved/sq_…",
    "savedQuery": { "id": "sq_…", "name": "Lumora Foods complaints" },
    "cadence": "weekly",
    "period": { "from": "2026-11-02T00:00:00.000Z", "to": "2026-11-09T00:00:00.000Z" },
    "count": 142,
    "previousCount": 103,
    "changePercent": 38,
    "stories": 117,
    "platforms": [{ "platform": "tiktok", "count": 61 }, { "platform": "twitter", "count": 49 }],
    "topVoices": [{ "platform": "tiktok", "handle": "…", "name": "…", "count": 6 }],
    "topPosts": [{ "key": "tiktok:7431…", "platform": "tiktok", "url": "https://www.tiktok.com/…", "text": "…" }],
    "spikes": [],
    "attention": { "needsReply": 0, "risk": 0 },
    "unrelated": 0,
    "riskPosts": []
  }
}
```

`period` runs from `from` up to, not including, `to`. `previousCount` and `changePercent` are null when the query is younger than the period before (and `changePercent` when that period had no posts). `topPosts` are in the search's mention shape, most engaged first. `spikes` lists the period's spikes, oldest first, each `{ at, count24h, baseline }`. Without a webhook endpoint, a query keeps only its latest spike, so a briefing then lists at most that one. The `headline` is put together from these numbers; nothing in a briefing is written by AI. With [insights](#step-8-let-it-read-every-post) on, the briefing is of the posts about the topic: `unrelated` counts the ones it left out, `attention` the posts that need a reply or are a risk (the headline ends with them, as in "; 2 need a reply, 1 risk"), and `riskPosts` lists up to 3 of the riskiest. Without insights these are zeros and an empty list.

## Step 8: Let it read every post

A keyword finds every post that says the word, whatever it means: "jaguar" finds cats as well as cars. Insights read each new match and say whether it's about your topic, what it is, and whether it needs a reply or is a risk. Turn them on with `insights` when you create or update a query, saying what the topic is about in a few words, and, if you like, up to 3 yes/no questions of your own:

```json
{ "insights": { "about": "Lumora Foods, the UK ready-meal brand", "questions": ["Does the post mention a refund?"] } }
```

Each new match then has `insights` on its `mention`: `relevance` (`about`, `passing` or `unrelated`), `kind` (`complaint`, `question`, `praise`, `buying`, `promotion`, `news` or null), `needsReply`, `risk`, your questions' `answers`, the `scores` behind them, and `source`. They're estimates from the post's text, so they can be wrong; a person can correct a post's relevance in the Listening app, and their answer (`source: "manual"`) wins.

* **Cost.** 1 credit per 10 posts given insights, on top of each run's `maxCreditsPerRun` but within `monthlyCreditCap` when you set one. A post keeps its insights: it's never judged or charged again. Posts with no text aren't judged or charged.
* **Nothing is hidden.** Every post is still kept and returned, whatever its relevance. Filter the matches route with `relevance` (`relevant` is anything but `unrelated`; posts without insights count as relevant), `attention` (`needs_reply` or `risk`) and `kind`.
* **Alerts count what matters.** With insights on, [spikes](#step-4-hear-about-spikes) and [volume alerts](#step-6-set-a-volume-alert) count only posts that aren't `unrelated`.
* **Numbers.** The [stats](#step-5-read-the-numbers) count posts first found in the window by their insights in `insights`: how many were judged and unrelated, how many need a reply or are a risk, each kind, and the yes answers to each of the query's questions. Flags, kinds and answers count only posts that aren't unrelated.
* **From the next run.** Turning insights on applies to posts found from then on; set `insights` to `null` to turn them off.

Insights aren't available to a workspace with zero data retention.

## How runs work

* **One at a time.** A query runs once at a time. A scheduled time that comes while a run is still going is skipped, and the next run searches from the last one.
* **From the last run.** Each scheduled run searches posts from a day before the previous run, so a post a platform shows late is still found. Posts found again aren't stored twice.
* **Run it now** with [`POST /v1/saved-queries/{id}/run`](/docs/api/v1/saved-queries/id/run/post). It answers `202` with the run while it searches; read its result from the query's runs. A run still going answers `429` with `Retry-After`.
* **Statuses.** `active` runs on its schedule. `paused` waits until you set `status: "active"`. `capped` waits for next month or a higher cap. `broken` follows five failed runs in a row, with the reason in `statusReason`; resume it to try again.
* **Each run's record** is in [`GET /v1/saved-queries/{id}/runs`](/docs/api/v1/saved-queries/id/runs/get): when it ran, what it charged, how many posts it matched and how many were new, and how far it got on each platform.
* **Changing the query** keeps the old wording as a version; each match says which version first found it.

A query saved with an API key runs with that key: its runs count toward the key's monthly limit, and if the key is deleted or disabled, the query pauses and says why.