URL: /code/data/keywords/forecast · Id: `page:/code/data/keywords/forecast`

# Keyword.forecast

> Price a paid test before any ad account spends: the clicks, impressions and spend your terms would get next month at your bid.

`POST https://api.sb/{your-domain}/keywords/forecast`

[Price](https://startupbuilder.dev/business/nouns/price) a paid test before any ad account spends: the clicks, impressions and spend your terms would get next month at your bid.

- **Price:** 9¢ a forecast
- **Success rate:** 100% of 97 calls
- **Freshness:** Fresh for 7 days
- **Emits:** `Keyword.forecasted`
- **Capability:** [Market data](https://startupbuilder.dev/systems/marketing/market-data)

```text title="Run it · Ask"
What would a month of exact-match ads on two rent-tracking terms cost at a $12 bid? Spend up to 10¢.
```

```ts title="Run it · SDK"
import { $ } from 'startup-builder'

const result = await $.try.Keyword.forecast({ keywords: ['rent tracking app for landlords', 'rent collection software'], bid: 12, match: 'exact', maxCost: 0.1 })
```

```json title="Run it · Response"
{
  "keywords": [
    "rent tracking app for landlords",
    "rent collection software"
  ],
  "bid": 12,
  "match": "exact",
  "period": "next-month",
  "clicks": 63,
  "impressions": 1510,
  "ctr": 0.042,
  "averageCpc": 8.75,
  "spend": 551.25,
  "perKeyword": [
    {
      "term": "rent tracking app for landlords",
      "clicks": 41,
      "spend": 358.75
    },
    {
      "term": "rent collection software",
      "clicks": 22,
      "spend": 192.5
    }
  ],
  "venue": "google-search",
  "locale": "en-US",
  "observedAt": "2026-10-27T14:02:11Z",
  "method": "estimated",
  "cost": {
    "usd": 0,
    "maxUsd": 0.09,
    "units": 1,
    "misses": 0
  }
}
```

## Inputs
| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `keywords` | `string[]` | Required | Up to 1,000 terms, priced as one set. |
| `bid` | `number` | Required | Your most per click, in USD. Start at your break-even cost per click, never above it. |
| `match` | `'exact' \| 'phrase' \| 'broad'` | `'exact'` | Run a first test on exact match only; broad match spends on terms you did not choose. |
| `venue` | `'google-search' \| 'bing'` | `'google-search'` | The search site to forecast. |
| `locale` | `string` | `your home Market` | The market and language, such as `en-US`. |
| `period` | `'next-month'` | `'next-month'` | The window the forecast covers. |
| `maxCost` | `number` | `0.20` | The most this call may spend, in USD. Over it, the call is refused before it spends, or stops early with what it has. |

## Outputs
| Field | Type | Unit | What `null` means |
| --- | --- | --- | --- |
| `clicks` | `number \| null` | clicks in `period` | Too little volume to forecast, never zero clicks. |
| `impressions` | `number \| null` | impressions in `period` | Too little volume to forecast. |
| `ctr` | `number \| null` | share, 0.042 is 4.2% | Too little volume to forecast. |
| `averageCpc` | `number \| null` | USD a click | Too little volume to forecast. |
| `spend` | `number \| null` | USD in `period` | Too little volume to forecast. |
| `perKeyword` | `{ term: string; clicks: number \| null; spend: number \| null }[]` | None. | Never null: one row per term; `null` where a term alone is too thin to forecast. |
| `keywords` | `string[]` | None. | Never null: the set forecast. |
| `bid` | `number` | USD | Never null. |
| `match` | `'exact' \| 'phrase' \| 'broad'` | None. | Never null. |
| `period` | `string` | None. | Never null. |
| `(row)` | [`Observed`](/code/data/types#observed) | None. | The forecast carries `observedAt`, `method`, `venue` and `locale`; `method` is `reported` from your own ad account. |
| `cost` | [`Cost`](/code/data/types#cost) | USD | Never null. In try mode `usd` is 0 and `maxUsd` is what the call would have cost. |

## Read the result
| Field | Value | What it means | What to do |
| --- | --- | --- | --- |
| `averageCpc` | Over $4.29 for a $149 a month product | Every click costs more than a customer it might become is worth in a year. | Earn these terms with pages, or test only with a price above $300 a month. (break-even cost per click, the strategy numbers) |
| `clicks` | Under 500 for the month | A paid test is read at 500 clicks; fewer cannot tell 1% from 3% signups apart. | Add exact terms until a month reaches 500 clicks, or plan a two-month test and budget it. (the 500-click reading rule) |
| `spend` | Over the budget you set | The bid buys more clicks than you can afford to read. | Lower the bid toward break-even, or cut the terms with the highest `perKeyword` spend. (the stop, hold or scale rule) |
| `ctr` | Under 3% on exact match | Forecast click rates assume an ad that echoes the term. | Put the term in headline 1 of every ad you test. (industry averages: 6.66% click rate across 16,000 advertisers) |

## Effects
**Action class**

None: it reads, so no approval rule names it

**Modes**

- `try`: Runs the read and returns real rows; nothing is saved and nothing is billed.
- `send`: Returns a pending Action at once; its Event carries the rows when the read finishes.
- `do`: Waits for the read and returns the rows, billed per result.

**Idempotency**

The same `Idempotency-Key` with the same input replays the first answer at $0; with other input it returns 409.

**Saved or shared**

Nothing is written to your records, and no ad account spends. Forecasts are shared across startups for the same terms, bid and match for 7 days.

**Emits**

`Keyword.forecasted` with `{ keywords, bid, match, clicks, impressions, spend }`

**Performed by**

[Paid Media Manager](https://startupbuilder.dev/agents/marketing/paid-media-manager)

**Price**

About 9¢ a forecast. Free: a forecast from your own Google Ads account. `maxCost` defaults to 20¢. One forecast for each of 3 bids on the same 40 terms: 3 × 9¢ a forecast is about 27¢.

**Reliability**

The first route succeeds 100% of 97 calls. If it fails, the call refuses with `sources-busy` and nothing is spent.

**Freshness**

A reading younger than 7 days returns at $0. Readings are shared across startups for the same subject and locale.

**Batch and pages**

Up to 1,000 terms a forecast. It answers in one response.

**Rate**

[The shared rate rules](/code/data#rate): 10 calls a second per key, and an array input counts as one call.

## Refuses when
| Check | Error | Fix | Then call |
| --- | --- | --- | --- |
| A search engine, answer engine or network the call does not read, such as ChatGPT sent to `Keyword.measure` | [`venue-unsupported`](/code/refusals#venue-unsupported) | Pick one the call’s `venue` input lists; AI answers are `Keyword.ask`, posts are `Keyword.search`. | [`Keyword.ask`](/code/data/keywords/ask) |

Every data call can also meet [the shared data-call refusals](/code/refusals#data-calls): [`filter-unsupported`](/code/refusals#filter-unsupported), [`input-missing`](/code/refusals#input-missing), [`limit-exceeded`](/code/refusals#limit-exceeded), [`max-cost`](/code/refusals#max-cost), [`rate-limited`](/code/refusals#rate-limited), [`sources-busy`](/code/refusals#sources-busy).

```json
{
  "type": "https://startupbuilder.dev/errors/venue-unsupported",
  "title": "The call does not read that search engine or network",
  "status": 422,
  "detail": "A search engine, answer engine or network the call does not read, such as ChatGPT sent to `Keyword.measure`.",
  "fix": "Pick one the call’s `venue` input lists; AI answers are `Keyword.ask`, posts are `Keyword.search`.",
  "next": {
    "verb": "Keyword.ask",
    "input": {
      "keyword": "best rent tracking software for a 40-unit property manager",
      "venues": [
        "chatgpt"
      ]
    }
  },
  "instance": "https://api.sb/leaseline.app/actions/action_1"
}
```

## When it waits
Returns in one response; nothing waits.

## Example

**Ask**

```text
What would a month of exact-match ads on two rent-tracking terms cost at
a $12 bid? Spend up to 10¢.
```

**API**

```bash
curl -X POST "https://api.sb/leaseline.app/keywords/forecast?mode=try" \
  -H "Authorization: Bearer $SB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"keywords":["rent tracking app for landlords","rent collection software"],"bid":12,"match":"exact","maxCost":0.1}'
```

The response is the one in the Run it window above.

**CLI**

```bash
npx startup-builder do Keyword.forecast --try --data '{"keywords":["rent tracking app for landlords","rent collection software"],"bid":12,"match":"exact","maxCost":0.1}'
```

**MCP**

```json
{
  "name": "do",
  "arguments": {
    "mode": "try",
    "code": "return await $.Keyword.forecast({ keywords: ['rent tracking app for landlords', 'rent collection software'], bid: 12, match: 'exact', maxCost: 0.1 })"
  }
}
```

**SDK**

```ts
import { $ } from 'startup-builder'

const result = await $.try.Keyword.forecast({
  keywords: ['rent tracking app for landlords', 'rent collection software'],
  bid: 12,
  match: 'exact',
  maxCost: 0.1,
})
```

To retry, send the same `Idempotency-Key`: the retry returns the first answer for $0. A new key reads again.

A `sk_test_` key answers these the same way every time, at $0 ([every test value](/code/data#test-values)).

| Test value | Always returns | What it is for |
| --- | --- | --- |
| `maxCost: 0.0001` | Refuses with [`max-cost`](/code/refusals#max-cost) | Any priced call with a maxCost of 0.0001 is refused before it spends: the price is above the cap. |
| `rate-limited.test` | Refuses with [`rate-limited`](/code/refusals#rate-limited) | Any call naming this hostname answers 429 with Retry-After, so your retry logic runs. |
| `sources-busy.test` | Refuses with [`sources-busy`](/code/refusals#sources-busy) | Any call naming this hostname answers 503 as if every route were busy, and nothing is spent. |

## Used by
- **Workflows:** [Ad test setup](/capabilities/run-ads/ad-test-setup)
- **Jobs:** [Run ads](/capabilities/run-ads)

## Related
[`Keyword.find`](/code/data/keywords/find) · [`Keyword.measure`](/code/data/keywords/measure) · [`Keyword.search`](/code/data/keywords/search) · [`Keyword.ask`](/code/data/keywords/ask)