# API reference — Trending Keywords for Your Niche: AI Google Trends Alerts

The developer tables behind the Store page: which call runs what, every run outcome, the rows `sourceMode: "list"`
accepts, the key-value records, one real dataset item, the `OUTPUT` shape and the webhook contract.

## Which call runs what — demo, live search or your own rows

| Input shape | `OUTPUT.mode` | Can it charge? |
|---|---|---|
| `{}` or the untouched form (the sample trends) | `demo` | no — sample rows are `chargedEvent: demo` |
| the sample trends + your own `nicheKeywords` (or `seedKeywords`) typed next to them | `live` | yes — per qualified trend, $0.01 per seed keyword (`OUTPUT.switchedToLive`) |
| the same with `autoSwitchToLive: false` | `demo` | no — the mode you sent runs; the unsearched keywords are named |
| `sourceMode: "actor"` + `nicheKeywords` / `seedKeywords` (the example niche too) | `live` | yes — as above |
| `preset: "<niche>"` (one of the 14 ready searches) | `live` | yes — as above; `agency-roster` runs `clients` |
| `previewOnly: true` + your niche | `preview` | no — counts, the weekly price, 3 examples |
| `calendarMode: true` + `seedKeywords` (≤ 50) | `calendar` | yes — $0.02 per keyword with a 5-year curve, nothing else |
| `clients: [...]` (not the untouched form example) | `clients` | yes — per trend + $0.10 per client report |
| `sourceMode: "list"` + your own `itemsList` rows | `list` | yes — per qualified trend, no source run |
| `sourceMode: "dataset"` + `datasetId` | `dataset` | yes — per qualified trend, no source run |

The form's example `clients` (Acme Fitness, Bloom Bakery) are never searched unless `preset` is `agency-roster`;
`@example.com` addresses are never mailed.

## Run outcomes — what your integration sees

Branch on `OUTPUT.ok` and `OUTPUT.outcome` (`ok` · `empty` · `source_refused` · `source_failed`) — never on the run
status or an empty dataset.

| Outcome | Run status | Dataset | `OUTPUT` | Charged? | What to do |
|---|---|---|---|---|---|
| Free demo (the pre-filled sample) | SUCCEEDED | sample trends | `ok: true`, `demo` | no | type your own `nicheKeywords` |
| Success | SUCCEEDED | qualified trends, most urgent first | `ok: true`, `outcome: ok`, funnel, `digest`, `sourceRuns`, `chargedEvents`, `aiCoverage`, `seedVerdicts` | per qualified trend + seed verdicts | — |
| First run of a search | SUCCEEDED | the last 7 days' trends | `firstRunBackfill` | as above | schedule it — the schedule reads the last 24 h |
| Narrow niche (< 3 feed matches) | SUCCEEDED | trends + your niche keywords read as seeds (free) | `nicheYield` | per qualified trend | schedule weekly, 7-day window |
| Nothing matched | SUCCEEDED | empty | `ok: true`, `outcome: empty`, `qualified: 0`, `suggestedTerms` | seed verdicts only | loosen `minScore` / filters, add niche keywords |
| Already paid under another search | SUCCEEDED | the trend, `chargedEvent: already-charged` | `chargeSplit.alreadyCharged` | no — one charge per trend per `dedupeStoreName` | — |
| Demo text still in place (no mode sent) | SUCCEEDED | empty | `inputNeeded` | no | replace `nicheKeywords` with your niche |
| Invalid input | FAILED | empty | — | no | fix the field the status message names |
| Spend cap reached before every Store run | SUCCEEDED | trends of the reads made | `sourceStatus.skippedByCap` | per qualified trend | raise `maxDiscoveryChargeUsd` |
| Google refused a built-in read | SUCCEEDED | the same, that read by the Store scraper | `sourceRuns[].reader: "store-actor"` | per qualified trend; the Store run on your plan | — |
| Your plan cannot run Store Actors (only a fallback needed it) | SUCCEEDED | the built-in reads | `sourceStatus.planRefusedRuns` | per qualified trend | retry later, or paste list-mode rows |
| Your plan cannot run Store Actors (nothing was read) | SUCCEEDED — the same for the Console, the API and a schedule | empty | `ok: false`, `outcome: source_refused`, `sourceError`, `sourceRefused: "plan"` | no | paste list-mode rows ([template](list-template.json)), or a plan that runs Store Actors |
| Source failed (every read: crash, timeout) | FAILED | empty | `ok: false`, `outcome: source_failed`, `sourceError` | no | follow the reason in the status message |
| Source format changed (the Store scraper renamed its fields) | FAILED | empty | `ok: false`, `outcome: source_failed`, `sourceError` names the fields seen | no | report it on the Issues tab; list-mode rows still work |
| Source partial (a Store run timed out or failed) | SUCCEEDED | trends from the rows read | `sourceStatus` | per qualified trend | raise `discoveryTimeoutSecs` (API only) or retry |
| AI unavailable | SUCCEEDED | rule-scored trends (the free demo: labelled sample AI verdicts, `aiModel: sample`) | `aiError`, `freeAiReserve`, `aiCoverage.sampleVerdicts` | free while your free trends last, then basic price | `llmProvider: byok` with your own key |
| Own key rate-limited | SUCCEEDED | AI rows until 60 s of waiting, then rule-scored | `aiCoverage.fallbackReasons` | AI rows at the AI price, the rest basic | a paid key, or Apify model access |
| Calendar | SUCCEEDED | empty | `calendar`, `calendarUrl`, `calendarCsvUrl` | $0.02 per keyword | schedule the same seeds weekly |

## List-mode rows — what a pasted record needs

`sourceMode: "list"` runs on any Apify plan: paste Google Trends rows you already have into `itemsList` — start from
the [list template](list-template.json) (2 trending rows and a seed row; export rows from Google Trends' own CSV, a
Sheets tab or any scraper run with these field names).

| Field | Needed? | What it enables |
|---|---|---|
| `term` | required (trending row) | the row's `name`; `title`, `query` or `name` work too |
| `geo` | optional | `geo`, `country`, the dedupe key (empty = worldwide) |
| `related_terms` | optional | related-search relevance, `relatedTerms`, the AI's grounding and `targetKeywords` |
| `trend_volume`, `trend_volume_formatted` | optional | `trendVolume`, `trendVolumeNumber`, `minTrendVolume`, `high_volume` |
| `rank`, `timeframe_hours`, `trends_url`, `timestamp` | optional | `rank`, urgency, `feedUrl`, `fetchedAt` |
| `keyword` + `timeline_data` | required (seed row) | a `seed_keyword` row with its momentum verdict; `data_granularity`, `timeframe` optional |
| `searchTerm` + `relatedQueries_rising[]`, or `seedKeyword` + `query` + `growthPercent` | required (rising row) | `rising_query` rows (apify / steadyfetch formats) |
| `Hashtag`, `Posts`, `TikTok URL` | required (TikTok row) | a `tiktok_hashtag` row (data_xplorer/tiktok-trends format) |

## Flags for targetFlags

The form shows them as a list to pick from; an API call passes these names (camelCase `nicheInTerm` works too) and an
unknown name stops the run with the valid list.

| Flag (`targetFlags` value) | Meaning |
|---|---|
| `niche_in_term` | one of your niche keywords is in the trending search itself |
| `niche_in_related` | your niche keywords appear only in its related searches |
| `high_volume` | 100K+ searches in the trending window |
| `breakout` | the seed's curve at its maximum (≥ 2× median), or a Breakout query |
| `seasonal_peak` | the seed's yearly peak is due within 8 weeks |
| `rising_query` | a rising related query of one of your seed keywords |
| `commercial_intent` | buying words (buy, price, best, vs, review …) |
| `sensitive_topic` | tragedy, politics, health claim, adult or legal words |

## Key-value records

| Record | What it holds |
|---|---|
| `OUTPUT` = `RUN_SUMMARY` | the whole run (shape below) |
| `DIGEST` | Markdown digest — what to act on first per country, with links |
| `REPORT` / `REPORT-<client>` | the shortlist as a page to forward; own reports link back to the Store page |
| `DOSSIER-n` | a one-page brief per top trend (`dossierTopN`) |
| `CALENDAR`, `CALENDAR_CSV` | the seasonal keyword calendar (`calendarMode`) |

`OUTPUT.seedVerdicts` — one entry per seed keyword the built-in reader read (`seed-verdict` each), delivered or
filtered: `{"seed", "geo", "momentumVerdict", "changePercent", "nextSeasonalPeak", "delivered", "url"}`.

`OUTPUT.calendar` — one entry per keyword with a 5-year curve, in publishing order: `{"seed", "geo", "peakMonth",
"peakStrength", "seasonal", "startPublishing", "nextPeak", "monthlyIndex" (Jan → Dec, 0–100), "years", "url"}`.

### The `OUTPUT` shape

The top-level keys of a successful run, with their types (`null` when the run had nothing to say):

`ok`: bool · `outcome`: str · `mode`: str · `client`: str | null · `qualified`: int · `candidates`: int · `stats`:
object · `chargedEvents`: object · `unchargedEvents`: object | null · `freeTierThisRun`: int · `demoRows`: int | null ·
`freeTierRemaining`: int | null · `freeAiReserve`: int | null · `chargeSplit`: object (`demo`, `free`, `basic`, `ai`,
`alreadyCharged`, `freeKeptForAi`) | null · `budgetLimitReached`: bool · `targetFlags`: list | null · `yieldByQuery`:
object · `nicheYield`: object | null · `suggestedTerms`: object | null · `webhook`: object (`sent`, `failed`,
`failedKeys`, `signed`) | null · `dedupeStore`: object | null · `aiError`: str | null · `seedVerdicts`: list | null ·
`alerts`: object | null · `trend`: object | null · `history`: list | null · `aiCoverage`: object | null · `aiTokens`:
object | null · `sourceError`: str | null · `sourceStatus`: object | null · `sourceRuns`: list | null ·
`sourceCostEstimateUsd`: number | null · `counts`: object | null · `timing`: object · `scheduleInput`: object ·
`scheduleTaskUrl`: str | null · `timeSaved`: str | null · `digest`: object · `reportUrl`: str | null · `nextSteps`:
list | null.

### One real dataset item

The AI row of the local `INPUT.ai.json` run on 2026-10-17 (`timeline` left out — it is `null` unless
`includeTimeline` is on):

```json
{"itemId": "f45e3a9d2047", "name": "wall pilates", "url": "https://trends.google.com/trends/explore?q=wall+pilates&geo=US&date=now+1-d", "feedUrl": "https://trends.google.com/trending?geo=US&hl=en-US", "platform": "google", "client": null, "trendType": "trending_search", "urgency": "publish_now", "score": 95, "ruleScore": 80, "label": "hot", "geo": "US", "country": "United States", "rank": 14, "trendVolume": "50K+", "trendVolumeNumber": 50000, "relatedTerms": ["wall pilates workout", "28 day wall pilates challenge", "wall pilates for beginners"], "timeframeHours": "24", "seedKeyword": null, "risingGrowthPercent": null, "risingValue": null, "momentumVerdict": null, "changePercent": null, "currentValue": null, "peakValue": null, "peakPeriod": null, "nextSeasonalPeak": null, "dataGranularity": null, "matchedNicheTerms": ["pilates", "workout"], "relevanceReasons": ["'pilates' in the search itself", "'workout' in related searches ('wall pilates workout')"], "flags": ["niche_in_term"], "targetFlagsMatched": [], "fitScore": 95, "isRelevant": true, "scoreReasons": ["The term 'wall pilates' matches your niche.", "Related searches include 'wall pilates workout' and '28 day wall pilates challenge'.", "High search volume of 50K+ in the United States."], "whyItMatters": "Related searches 'wall pilates workout', '28 day wall pilates challenge', and 'wall pilates for beginners' show high interest in home fitness routines for women aged 30–50 using simple equipment.", "audienceIntent": "informational", "sensitivity": "none", "sensitivityReason": null, "suggestedAngle": "Try Our 28-Day Wall Pilates Routine at Home!", "contentFormat": "blog", "targetKeywords": ["wall pilates", "wall pilates workout", "28 day wall pilates challenge", "wall pilates for beginners"], "fetchedAt": "2026-09-28T08:30:00+00:00", "status": "qualified", "charged": false, "aiModel": "google:gemini-3.5-flash-lite", "assessedBy": "ai", "chargedEvent": "demo", "billedAs": "qualified-trend-ai", "dedupeKey": "trending_search:US:wall pilates", "sourceActor": "list", "source": {"mode": "list", "query": "US"}, "scrapedAt": "2026-10-17T06:40:18+00:00"}
```

## Webhook payloads

- `webhookBatchSize: 1` — `{"event": "trend.qualified", "trend": {…}, "runId": "…"}` per trend.
- a larger size — `{"event": "trends.qualified", "trends": [{…}, …], "runId": "…"}` per batch.
- `trendAlerts: true` — `{"event": "trend.alert", "trend": {…}, "runId": "…"}` per publish-now trend at or above
  `alertMinScore`, sent as soon as the run's trends are pushed — before the digest.

With `webhookSecret` every POST carries `X-Signature: sha256=<HMAC-SHA256 of the raw body with your secret>` — verify
it before you trust the payload. A 429 / 5xx is retried once after 2 s; a POST that still fails is counted in
`OUTPUT.webhook.failed`, its trends' `dedupeKey`s are listed in `OUTPUT.webhook.failedKeys`, and it never fails the
run. A failed trend is **not** re-sent next run (it was delivered and charged) — re-post it from the dataset by its
`dedupeKey`.

## Chat and alert delivery

**Send alerts to** (`sendAlertsTo`) takes any number of URLs, one per line, and routes each by its address: a Slack
incoming webhook, a Discord channel webhook, a Teams Workflows webhook, a Google Chat space webhook, a Telegram bot's
send link (`https://api.telegram.org/bot<token>/sendMessage?chat_id=@yourchannel`) — anything else https is the JSON
webhook. API callers may still pass `slackWebhookUrl`, `discordWebhookUrl`, `teamsWebhookUrl`, `googleChatWebhookUrl`
and `telegramUrl` (a per-service field wins over the same service in `sendAlertsTo`).
