URL: /code/apps/seo/track · Id: `page:/code/apps/seo/track`

# Keyword.track

> Reads, each day, where your pages rank for every term you track and which AI answers cite you, one row per page, term and day.

POST https://api.sb/&#123;your-domain&#125;/keywords/track

Reads, each day, where your pages rank for every term you track and which AI answers cite you, one row per page, term and day.

- **Action class:** None
- **Approval:** Runs on its own
- **Emits:** `Keyword.tracked`
- **Price:** 3¢ per 100 reads

```text title="Run it · Ask"
Track my 20 terms on Google every day, and tell me where the rent ledger template page stands today.
```

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

const result = await $.try.Keyword.track({ venues: ['google-search', 'chatgpt'], every: '1d', since: '7d' })
```

```json title="Run it · Response"
{
  "api": "https://api.sb/leaseline.app",
  "tracking": {
    "keywords": 20,
    "venues": [
      "google-search",
      "chatgpt"
    ],
    "every": "1d",
    "nextReadAt": "2026-10-28T06:00:00Z"
  },
  "history": [
    {
      "keyword": "https://api.sb/leaseline.app/keywords/keyword_Rl1tE5mP3x",
      "term": "rent ledger template",
      "page": "https://leaseline.app/rent-ledger-template",
      "venue": "google-search",
      "locale": "en-US",
      "day": "2026-10-27",
      "position": 14,
      "feature": "organic",
      "cited": null,
      "change": 3,
      "observedAt": "2026-10-27T06:10:00Z",
      "method": "sampled"
    },
    {
      "keyword": "https://api.sb/leaseline.app/keywords/keyword_Rl1tE5mP3x",
      "term": "rent ledger template",
      "page": "https://leaseline.app/rent-ledger-template",
      "venue": "chatgpt",
      "locale": "en-US",
      "day": "2026-10-27",
      "position": null,
      "feature": "answer",
      "cited": true,
      "change": null,
      "observedAt": "2026-10-27T06:12:00Z",
      "method": "sampled"
    }
  ],
  "cost": {
    "usd": 0,
    "maxUsd": 0.52,
    "units": 40,
    "misses": 0
  },
  "action": {
    "operation": "Keyword.track",
    "mode": "try",
    "status": "done"
  }
}
```

## Inputs
| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `keywords` | `Keyword[]` | `every saved Keyword` | The terms to track, as `Keyword.create` saved them. Unset: every Keyword the startup saved. |
| `venues` | `('google-search' \| 'bing' \| 'chatgpt' \| 'perplexity' \| 'gemini' \| 'claude')[]` | `['google-search']` | Where to read. A search engine returns a position; an answer engine returns whether the answer cites your site. |
| `locale` | `string` | `your home Market` | The market and language each read is taken in, such as `en-US`. |
| `every` | `'1d' \| '1w'` | `'1d'` | How often each term is read. Answer engines read weekly whatever this says, since their answers move slower than results pages. |
| `depth` | `number` | `100` | How many results deep a search read looks: a page past it reads `position: null`. |
| `since` | `string` | `'30d'` | How much history the response returns: `7d`, `30d`, `90d`. Reading history is free. |
| `maxCost` | `number` | `5.00` | The most tracking may spend in a month, in USD. Over it, the next day’s reads are skipped and the call says so. |

## Outputs
| Field | Type | What `null` means |
| --- | --- | --- |
| `tracking` | `{ keywords: number; venues: VenueId[]; every: string; nextReadAt: string }` | Never null: what is tracked from now on, and when the next read runs. |
| `history` | `RankHistory[]` | Never null: one row per page, term, Venue and day over `since`; empty on the first call, before any read. |
| `history[].page` | `string \| null` | None of your pages appeared in the first `depth` results, or the answer cited none of them. |
| `history[].position` | `number \| null`, in rank, 1 is the top | Not in the first `depth` results, or an answer engine, which has no positions. |
| `history[].feature` | `'organic' \| 'featured-snippet' \| 'ai-overview' \| 'local-pack' \| 'answer' \| null` | Your page did not appear. |
| `history[].cited` | `boolean \| null` | A search engine row: only an answer engine cites. |
| `history[].change` | `number \| null`, in positions since the read before | The first read of this page for this term, or an answer engine row. |
| `history[]` | [`Observed`](/code/data/types#observed) | Each row also carries `observedAt`, `method`, `venue` and `locale`. |
| `cost` | [`Cost`](/code/data/types#cost), in USD | Never null: what today’s reads spent. In try mode `usd` is 0 and `maxUsd` is a month at this setting. |

## Effects
**Action class**

None: no approval rule names it, so it runs within its seat’s Permissions and Limits

**Modes**

- `try`: Runs one read of each term now and returns its rows, unsaved; nothing is scheduled and nothing is billed.
- `send`: Saves the setting and returns the stored history at once; each scheduled read emits `Keyword.tracked` with its rows.
- `do`: Saves the setting, runs today’s read, and returns the history with today’s rows in it.

**Idempotency**

Only with a key: the same `Idempotency-Key` and input return the first Action, and the same key with other input returns `409`. Calling it again with other `keywords` or `venues` changes what is tracked from the next read; history is never rewritten.

**Saved or shared**

The tracking setting and every RankHistory row, kept for as long as the startup exists. Results pages are shared readings, as on `Keyword.search`; which of your pages ranked is yours alone.

**Emits**

`Keyword.tracked` with `{ keyword, rows }`

**Performed by**

[Content lead](https://startupbuilder.dev/agents/marketing/content-lead)

**Price**

About 3¢ per 100 reads. At most 40¢ per 100 reads. Free: history reads, a term no page of yours targets yet, until it is saved.

## On each Venue
It acts on your own records and reaches no Venue, so every account answers the same way.

## What try does
Runs every check the call meets and returns the record as it would be; nothing is stored, sent or posted.

## Refuses when
| Check | Error | Fix | Then call |
| --- | --- | --- | --- |
| A data call whose price for what you asked is above its `maxCost`, checked before anything is spent | [`max-cost`](/code/refusals#max-cost) | Lower `limit`, or raise `maxCost` to the price the refusal’s detail quotes. | [`Organization.find`](/code/data/organizations/find) |
| 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) |

```json
{
  "type": "https://startupbuilder.dev/errors/max-cost",
  "title": "The call would spend more than its cap",
  "status": 422,
  "detail": "A data call whose price for what you asked is above its `maxCost`, checked before anything is spent.",
  "fix": "Lower `limit`, or raise `maxCost` to the price the refusal’s detail quotes.",
  "next": {
    "verb": "Organization.find",
    "input": {
      "question": "US property managers with 20 to 400 units who still track rent in spreadsheets",
      "limit": 25,
      "maxCost": 0.05
    }
  },
  "instance": "https://api.sb/leaseline.app/actions/action_1"
}
```

## When it waits
In `send` mode it returns the stored history at once, before the next read runs, and `Keyword.tracked` delivers the result.

```ts
await $.Watch.create({ selector: 'Keyword.tracked', url: 'https://leaseline.app/hooks/sb' })
```

## Example
The second call, a week on: the history alone, free, for the one term the readout flagged.

**Ask**

```text
How did the rent ledger template page move on Google this week?
```

**API**

```bash
curl -X POST "https://api.sb/leaseline.app/keywords/track?mode=try" \
  -H "Authorization: Bearer $SB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "keywords": [
    "https://api.sb/leaseline.app/keywords/keyword_Rl1tE5mP3x"
  ],
  "venues": [
    "google-search"
  ],
  "since": "7d"
}'
```

**CLI**

```bash
npx startup-builder do Keyword.track --data '{
  "keywords": [
    "https://api.sb/leaseline.app/keywords/keyword_Rl1tE5mP3x"
  ],
  "venues": [
    "google-search"
  ],
  "since": "7d"
}'
```

The CLI runs in try mode unless you pass `--do`.

**MCP**

```json
{
  "name": "do",
  "arguments": {
    "mode": "try",
    "code": "return await $.Keyword.track({ keywords: ['https://api.sb/leaseline.app/keywords/keyword_Rl1tE5mP3x'], venues: ['google-search'], since: '7d' })"
  }
}
```

**SDK**

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

const result = await $.try.Keyword.track({
  keywords: ['https://api.sb/leaseline.app/keywords/keyword_Rl1tE5mP3x'],
  venues: ['google-search'],
  since: '7d',
})
```


```json title="Response, in try mode"
{
  "api": "https://api.sb/leaseline.app",
  "tracking": {
    "keywords": 1,
    "venues": [
      "google-search"
    ],
    "every": "1d",
    "nextReadAt": "2026-11-03T06:00:00Z"
  },
  "history": [
    {
      "keyword": "https://api.sb/leaseline.app/keywords/keyword_Rl1tE5mP3x",
      "term": "rent ledger template",
      "page": "https://leaseline.app/rent-ledger-template",
      "venue": "google-search",
      "locale": "en-US",
      "day": "2026-10-27",
      "position": 14,
      "feature": "organic",
      "cited": null,
      "change": 3,
      "observedAt": "2026-10-27T06:10:00Z",
      "method": "sampled"
    },
    {
      "keyword": "https://api.sb/leaseline.app/keywords/keyword_Rl1tE5mP3x",
      "term": "rent ledger template",
      "page": "https://leaseline.app/rent-ledger-template",
      "venue": "google-search",
      "locale": "en-US",
      "day": "2026-11-02",
      "position": 9,
      "feature": "organic",
      "cited": null,
      "change": 5,
      "observedAt": "2026-11-02T06:10:00Z",
      "method": "sampled"
    }
  ],
  "cost": {
    "usd": 0,
    "maxUsd": 0,
    "units": 0,
    "misses": 0
  },
  "action": {
    "operation": "Keyword.track",
    "mode": "try",
    "status": "done"
  }
}
```

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/checks#test-values)).

| Test value | Always returns | What it is for |
| --- | --- | --- |
| `keywords: ['keyword_test_track']` | `{"history":[{"term":"rent ledger template","position":14,"feature":"organic"}]}` | Always position 14 on Google, so the step that fires on page one can be tested with a known miss. |

## Used by
None.

## Related
[`Keyword.create`](/code/apps/seo/create) · [`Keyword.update`](/code/apps/seo/update-keyword) · [`Monitor.update`](/code/apps/seo/update-monitor)