Run a Boolean query on a schedule
Save a Boolean query, let Social Fetch search it every few hours, keep every post it matches, and get new matches, spikes, volume alerts and daily or weekly briefings as signed webhooks
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.
What you'll need
- An API key.
- A query that finds what you mean. Check it for free with
POST /v1/query/validate, or write one from a description. - Optionally, a webhook endpoint 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
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
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; dedupe on socialfetch-event-id.
{
"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 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:
{
"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. Nothing is searched, so it costs no credits.
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.
dailyhas 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.platformscounts each platform's posts first found in the window, with or without a time, most first.totalsis their sum.topAuthorsandtopSiteslist 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, withoutwww., 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 itsxn--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:
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}; send null to remove it.
{ "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:
{
"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):
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:
{
"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 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:
{ "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
maxCreditsPerRunbut withinmonthlyCreditCapwhen 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(relevantis anything butunrelated; posts without insights count as relevant),attention(needs_replyorrisk) andkind. - Alerts count what matters. With insights on, spikes and volume alerts count only posts that aren't
unrelated. - Numbers. The stats 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
insightstonullto 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. It answers202with the run while it searches; read its result from the query's runs. A run still going answers429withRetry-After. - Statuses.
activeruns on its schedule.pausedwaits until you setstatus: "active".cappedwaits for next month or a higher cap.brokenfollows five failed runs in a row, with the reason instatusReason; resume it to try again. - Each run's record is in
GET /v1/saved-queries/{id}/runs: 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.
Write a Boolean query from a description
Describe what to monitor in plain words, get a Boolean query written by AI and checked before anything runs, refine it, and check your own edits
API reference
Browse Social Fetch endpoints by platform and task, with direct links to generated schema pages and the raw OpenAPI spec