> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nephia.cc/llms.txt
> Use this file to discover all available pages before exploring further.

# Keywords

> A keyword is what you monitor. It carries its terms, its Sources, its interval, its webhook and its exclusions.

A **keyword** is the one object you create to monitor something: a brand, a competitor, a
topic. It carries everything the monitoring needs:

* its **terms**: the search text, either a single `query` or a list of `terms` OR'd into one
  request (a brand plus its variants), see [Limits: Terms per search](/limits#terms);
* its **Sources**: the platforms you switch on, each one polled on its own schedule;
* its **interval**: `refreshIntervalSeconds`, with an optional override per Source;
* its **webhook**: an optional `webhookUrl` that receives each match as it is found, see
  [Webhooks](/webhooks);
* its **exclusions**: `muteRules`, the words, authors and communities you never want to see.

Nephia polls every enabled Source on the interval, keeps what matches, reads each mention
for sentiment and intent, and sorts it into your [buckets](#buckets). You read the result
back with `GET /v1/keywords/{id}/results` or `GET /v1/mentions?keyword={id}`, both free.

<Note>
  Keyword bodies are **camelCase**, unlike the rest of this API. A keyword is the one object
  you round-trip (read it, change a field, send it back), so its body is spelled the way its
  response is. See [Overview](/api-reference/overview).
</Note>

## Lifecycle

| Action             | Method                                                                                 | Notes                                                                                                |
| ------------------ | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| List               | `GET /v1/keywords`                                                                     | Every keyword on your Account, under the `keywords` key, with each Source's `health`                 |
| Get                | `GET /v1/keywords/{id}`                                                                | One keyword, with what each search actually polls and its attached channels                          |
| Estimate           | `POST /v1/keywords/estimate`                                                           | Prices a create body without creating or charging anything                                           |
| Create             | `POST /v1/keywords`                                                                    | Starts polling every enabled Source                                                                  |
| Estimate an update | `POST /v1/keywords/{id}/estimate`                                                      | Prices a patch without changing anything                                                             |
| Update             | `PATCH /v1/keywords/{id}`                                                              | A real patch: a key you do not send is left alone                                                    |
| Read               | `GET /v1/keywords/{id}/results`                                                        | The mentions it caught, with their bucket                                                            |
| Events             | `GET /v1/keywords/{id}/events`                                                         | The same catches as raw events, each in the body its webhook delivers. See [Events](#events)         |
| Activity           | `GET /v1/keywords/{id}/activity`                                                       | Every check and every webhook delivery. See [Activity](#activity)                                    |
| AI answer runs     | `GET /v1/keywords/{id}/ai-answers/runs`                                                | Every stored run of the AI answers Source. See [AI answers](/sources/ai-answers#reading-the-history) |
| Pause              | `POST /v1/keywords/{id}/pause`                                                         | Stops every Source; nothing is polled or charged                                                     |
| Resume             | `POST /v1/keywords/{id}/resume`                                                        | Restarts every Source that can follow                                                                |
| Pause a Source     | `POST /v1/keywords/{id}/sources/{source}/pause`                                        | Stops one Source, leaves the others polling                                                          |
| Resume a Source    | `POST /v1/keywords/{id}/sources/{source}/resume`                                       | Restarts that Source                                                                                 |
| Buckets            | `GET`, `POST /v1/keywords/{id}/buckets`, `DELETE /v1/keywords/{id}/buckets/{bucketId}` | See [Buckets](#buckets)                                                                              |
| Brand              | `GET`, `PUT /v1/keywords/{id}/brand`                                                   | See [Brand](#brand)                                                                                  |
| Delete             | `DELETE /v1/keywords/{id}`                                                             | Retires the keyword; `?purge=true` also deletes its mentions                                         |

Every read, estimate, pause and resume is free. What costs credits is the polling a keyword
does, see [Billing](#billing).

<h2 id="create">
  Create a keyword
</h2>

```bash theme={null}
curl -X POST https://api.nephia.cc/v1/keywords \
  -H "x-api-key: YOUR_API_KEY" \
  -H "content-type: application/json" \
  -d '{
        "name": "figma",
        "refreshIntervalSeconds": 900,
        "globalCriteria": { "market": "fr", "terms": ["figma", "figma alternative"] },
        "sources": [
          { "source": "reddit", "enabled": true },
          { "source": "x", "enabled": true }
        ],
        "aiEnabled": true,
        "sentimentEnabled": true
      }'
```

`name`, `refreshIntervalSeconds`, `globalCriteria` and `sources` are required. The answer
is **201** with the keyword.

| Field                                         | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `globalCriteria`                              | What every search inherits: `market` (required), then either `query` or `terms`, and an optional [`match`](#match-mode).                                                                                                                                                                                                                                                                                                                                                                    |
| `sources`                                     | At least one entry: `source`, `enabled`, an optional `refreshIntervalSeconds` for this Source alone (`null` goes back to the keyword's), and optional `searches`. An enabled Source with no `searches` gets one search that inherits `globalCriteria`.                                                                                                                                                                                                                                      |
| `sources[].searches`                          | Each search may carry a `label` and `overrides`, the criteria fields it changes for itself, applied when `overridesEnabled` is true. A Source's own criteria (a Reddit `subreddit`, an RSS `feedUrl`, an AI answers `prompt`) go in `overrides.filters`; each [Source page](/sources/x) lists its own. On Vinted that is where `brand_ids`, `size_ids`, `catalog_ids`, `color_ids` and `status_ids` go, beside `market`, `priceMin` and `priceMax`: see [Vinted](/sources/vinted#coverage). |
| `webhookUrl`                                  | Where matches are delivered. Optional; `null` or an empty string clears it.                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `webhookMode`                                 | `all` delivers every match, `rules` delivers only what one of the keyword's `rules` selects.                                                                                                                                                                                                                                                                                                                                                                                                |
| `webhookEvents`                               | Keyword-level events this webhook also receives, empty by default. See [Webhooks: Keyword events](/webhooks#keyword-events).                                                                                                                                                                                                                                                                                                                                                                |
| `channels`                                    | Delivery channels to attach, each as `{ "channelId", "mode" }`. An empty array detaches everything.                                                                                                                                                                                                                                                                                                                                                                                         |
| `muteRules`                                   | The exclusions. Each is a bare string (that word, every Source) or an object: `value`, plus optional `target` (`text`, `title`, `body`, `author`, `community`), `source` and `match` (`word`, `contains`, `exact`).                                                                                                                                                                                                                                                                         |
| `vipAuthors`                                  | Authors to flag: a bare handle, or `{ "handle", "source", "note" }`.                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `rules`                                       | Alert and routing rules. An empty array removes every rule.                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `spikeAlert`                                  | Create only. `low`, `medium` or `high` seeds a volume alert rule; `null` opts out. Omitted means `medium`.                                                                                                                                                                                                                                                                                                                                                                                  |
| `aiEnabled`, `aiPreset`, `aiPrompt`, `aiStep` | The reading each mention gets, and your own [agent step](/ai).                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `sentimentEnabled`                            | Sentiment and intent on each mention. Omitted means your plan's default.                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `coBrandsEnabled`                             | On an AI answers Source, also extracts the other brands an answer names. Off unless you turn it on.                                                                                                                                                                                                                                                                                                                                                                                         |
| `subjectRole`                                 | What the keyword is **about**: `own` (the default), `competitor` or `topic`. It changes nothing about what gets polled; it is what a reply draft reads to decide whose voice it writes in.                                                                                                                                                                                                                                                                                                  |
| `schedule`                                    | An activation window: `startsAt`, `endsAt`, `timezone`. Omitted means always on.                                                                                                                                                                                                                                                                                                                                                                                                            |
| `backfill`                                    | Create only. See [Day-one backfill](#backfill).                                                                                                                                                                                                                                                                                                                                                                                                                                             |

The body, the plan limits, the interval floors and the multi-term rules are exactly the
ones the dashboard applies: a keyword this refuses is one the Configure screen would refuse
too, with the same error code.

<h2 id="estimate">
  Estimate before you write
</h2>

`POST /v1/keywords/estimate` takes the create body and answers what the create would do,
**without creating anything or charging anything**. It runs the create's own validation, so
a refused body comes back as `valid: false` with a `refusal` (`status`, `code`, `message`)
rather than as an error.

A valid body comes back priced under `estimate`:

* `monthlyCredits` and `checksPerMonth`, in total, per Source (`bySource`) and per search
  (`bySearch`);
* `firstScanCredits` when `backfill` is true: the **most** the first scan can cost, since on
  X it is charged per tweet returned;
* `planAllowance`, `creditsRemaining`, and `searchSlots` (`inUse`, `requested`, `limit`);
* `normalized`: the keyword as it would poll, after defaults and overrides are merged.

`POST /v1/keywords/{id}/estimate` does the same for a patch, and adds
`previousMonthlyCredits`, `monthlyCreditsDelta` and `resetSearches`.

Both answers carry an `estimateToken`, valid 15 minutes. Send it back in the
`Nephia-Estimate-Token` header of the write, with the **same** body, and the write refuses
a body that is not the one priced. The header is optional for an API key and required from
the [MCP server](/mcp-server).

<h2 id="update">
  Update a keyword
</h2>

`PATCH /v1/keywords/{id}` is a real patch: **a key you do not send is left alone**, and a
key sent as `null` clears it. The answer is `{ "keyword": …, "resetSearches": [...] }`.

The difference matters. A search whose terms, filters or market change is **started over**:
its first poll records what currently matches without emitting it, and `resetSearches`
names it (`source`, `searchId`, `label`). The mentions caught before the change stay on the
keyword. A new interval or webhook applies in place and starts nothing over.

<h2 id="backfill">
  Day-one backfill
</h2>

A new keyword starts empty: it reports what appears **after** it exists. Pass
`backfill: true` on create and it runs **one immediate search per enabled Source** and
stores the results as its starting history, so `GET /v1/keywords/{id}/results` answers with
content straight away.

Three things are worth knowing before you use it.

**It costs one tick per Source**, at that Source's rate (see [Billing](#billing)): it is a
real search, charged like any other poll. On X it is charged like an X search instead,
**5 credits plus 3 per tweet returned**, at most 65 for its page of 20, so the figure is a
ceiling and an empty page costs 5.

**It notifies nobody.** Seeded mentions fire no webhook and no channel. A backfill is
context, not news, and a create that immediately POSTed fifty old items to your endpoint is
how an integration gets switched off. Read them instead.

**It runs just after the create returns.** `POST` answers **201** as soon as the backfill
is queued, so the mentions appear a moment later, not in the response body.

Mentions that came from a backfill carry `"seeded": true`. Everything the keyword catches
on its own ticks from then on is delivered normally, and nothing is emitted twice. See
[History & backfill](/introduction#history) for what a keyword starts with.

<h2 id="match-mode">
  Match mode
</h2>

Search platforms do not all match the way you would expect. Some tolerate typos and
prefixes, some are fuzzy, some rank by relevance rather than text, so a keyword on a
distinctive brand name can return items that do not carry it anywhere.

`match` is an optional field of `globalCriteria` (and of a search's `overrides`). It says
how strictly a result must carry your terms:

| Value       | Behaviour                                                                                                                                                                                                             |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| *(omitted)* | The platform's own matching. This is the default.                                                                                                                                                                     |
| `platform`  | The same thing, stated explicitly.                                                                                                                                                                                    |
| `word`      | Keeps only items containing the text as a **whole word** (case- and accent-insensitive; a multi-word text must appear contiguously). The text is also sent quoted upstream where the platform honours quoted phrases. |
| `contains`  | Keeps only items containing the text as a **substring**. The text is always sent raw.                                                                                                                                 |

Filtering happens **before** a mention is stored, so it applies to every surface at once:
webhook deliveries, `/results`, `/v1/mentions`, and everything derived from them.

<Note>
  Under `word` and `contains`, an item whose text misses is still kept when one of its
  identities (the author's handle, the subreddit, the channel) is **exactly** your text.
  A post by `@nephia` is not noise, whatever it happens to say.
</Note>

Two things `match` does not touch. A search with no text (a Youtube `channel`, a Bluesky
`author`, an RSS `feed_url`, a Reddit `subreddit` on its own) has no term to be held to, so
nothing is filtered. And Vinted does not take the field at all: catalog search is not a
text match, and a listing titled "501" is a correct hit for "jean levis 501".

One case runs the other way. Reddit
[comments](/sources/reddit#comments) are read from a listing, not a search, so the text is
a **filter we apply** rather than a search the platform ran, and it applies under every
`match` mode, `platform` included. Setting `word` or `contains` there tightens our filter;
leaving `match` unset does not turn it off.

<h2 id="read">
  Read what it caught
</h2>

```bash theme={null}
curl "https://api.nephia.cc/v1/keywords/KEYWORD_ID/results?limit=50" \
  -H "x-api-key: YOUR_API_KEY"
```

The answer carries `since`, the `keyword` header (`id`, `name`, `kind`, `aiEnabled`,
`isActive`), `enabledSources`, the `mentions`, and a `nextCursor` when there is another
page. Each mention carries the `bucket` it was sorted into. Filter with `since`, `until`,
`source`, `q`, `bucket`, `intent`, `sentiment`, `engagement_min` or `engagement`.

`GET /v1/mentions?keyword=KEYWORD_ID` is the same read across the whole Account, narrowed
to one keyword. Each mention there names the `keyword` it came from and the `matchedTerms`
that caught it.

Both are free in the default `text` mode. `mode=semantic` retrieves by meaning and is
charged, see [Credits](/credits).

Before you trust a quiet page, look at the keyword itself. `GET /v1/keywords` and
`GET /v1/keywords/{id}` give each Source a `health`: `lastCheckedAt`, `lastOutcome`,
`consecutiveFailures` and `stale`. A **stale** Source should have been checked and was not,
so an empty page from it means nothing. The list also counts them per keyword in
`staleSources`.

<h2 id="events">
  Events
</h2>

`/results` answers **mentions**: each catch projected to a title, a body and a reading,
the same on every Source. `GET /v1/keywords/{id}/events` answers the raw **events** under
them, each one **exactly the body its webhook delivered**: `id`, `type`, `keywordId`,
`source`, the entity itself (`tweet`, `post`, `listing` and the rest), `previousPrice`,
`occurredAt`, `publishedAt`, `ai`, and `analysis` when something read the item.

```bash theme={null}
curl "https://api.nephia.cc/v1/keywords/KEYWORD_ID/events?since=2026-07-01T00:00:00.000Z&source=vinted" \
  -H "x-api-key: YOUR_API_KEY"
```

The answer is `{ "events": [...] }`, newest first, across every Source and every search of
the keyword, including what a search caught before its criteria last changed. `since`
defaults to the last 24 hours, `limit` to 200 (500 at most), and `source` narrows to one
Source. It is free.

Reach for it in two cases: your code already handles the webhook and you want to catch up
or poll instead ([Webhooks: Replay events](/webhooks#replay)), or you need what a mention
does not carry, such as Vinted's `listing.price_changed` with its `previousPrice`, and
`listing.delisted`.

<h2 id="activity">
  Activity
</h2>

`GET /v1/keywords/{id}/activity` is the diagnostic read: what Nephia did for this keyword,
check by check and delivery by delivery, newest first. It is free, takes the same `since`
(the last 7 days by default), `limit` and `source`, and answers `{ "activity": [...] }`.

| `type`              | What it records                                                                              |
| ------------------- | -------------------------------------------------------------------------------------------- |
| `poll.completed`    | A check ran: `eventsEmitted` and `creditsCharged`. A check that found nothing is listed too. |
| `poll.failed`       | A check failed, with the reason in `message`.                                                |
| `webhook.delivered` | A delivery your endpoint accepted: `eventId` and `statusCode`.                               |
| `webhook.failed`    | A delivery that failed: `eventId`, the `statusCode` your endpoint answered, and `message`.   |

Every entry also carries `id`, `source` and `occurredAt`; the fields that do not apply to
its `type` are `null`. `eventId` is an `id` of `/events`. Where a Source's `health` on the
keyword says **that** something is wrong (`stale`, `consecutiveFailures`), activity says
**what**: a run of `poll.failed` names the upstream error, and a run of `webhook.failed`
with a `statusCode` of 500 is your endpoint, not the polling.

<h2 id="pause">
  Pause and resume
</h2>

`POST /v1/keywords/{id}/pause` stops every Source. Nothing is polled, nothing is charged
and nothing is delivered until you resume; the mentions already caught stay readable.

`POST /v1/keywords/{id}/resume` starts every Source that can follow. A Source that support
stopped stays stopped and is named in `blockedSources` (`source`, `reason`) rather than
failing the call. Both answer `{ "keyword": …, "blockedSources": [...] }`.

`pausedReason` on the keyword tells you who stopped it: `user_paused` when you called
`pause`, `schedule` when it is outside its activation window, `insufficient_credits` when a
tick could not be debited, `plan_entitlement` when a downgrade left it outside your plan,
`admin_paused` when support paused it. Polling does not resume on its own after a top-up
or an upgrade: call `resume`.

<h3 id="sources">
  One Source at a time
</h3>

```bash theme={null}
curl -X POST "https://api.nephia.cc/v1/keywords/KEYWORD_ID/sources/reddit/pause" \
  -H "x-api-key: YOUR_API_KEY"
```

Stops one Source and leaves the others polling. Its searches and the mentions it caught
stay. It is refused when the Source is not enabled on the keyword, when the keyword itself
is paused, and when it is the last Source still polling: pause the keyword instead.
`…/sources/{source}/resume` starts it again.

<h2 id="buckets">
  Buckets
</h2>

A keyword sorts its mentions into **buckets**: a name and one plain-language sentence
saying what belongs in it. The model reads the sentence, not a word list.

* `GET /v1/keywords/{id}/buckets` lists them with a `count` each, plus `unclassifiedCount`
  and `pendingCount` (mentions no sorting pass has read yet). It never triggers a sorting
  pass, so listing buckets cannot bill.
* `POST /v1/keywords/{id}/buckets` adds one: `label`, `rule`, and an optional `color`.
  Mentions already sorted stay where they are; the next pass reads the new bucket. **409**
  when a bucket of that name exists.
* `DELETE /v1/keywords/{id}/buckets/{bucketId}` retires one. Its mentions are not deleted:
  they become uncategorised, and `unclassified` in the answer says how many.

Filter `/results` to one bucket with `bucket=<id>`, or to the unsorted ones with
`bucket=uncategorised`. A keyword of `kind: catalog` (Vinted listings) has no buckets.

<h2 id="brand">
  Brand
</h2>

Reply drafts speak for one of the brands on your Account. `GET /v1/brands` lists them
(`id`, `name`, `website`, `isDefault`). `GET /v1/keywords/{id}/brand` answers what the
keyword stores (`brandProfileId`, `null` for the Account's default) and the `brand` that
resolves to. `PUT /v1/keywords/{id}/brand` with `{ "brandProfileId": "<id>" }` chooses one,
and `null` goes back to the default. Nothing is polled differently and no search starts
over. A brand id that is not on your Account is a **404**. The brand card itself is edited
in the dashboard and is not part of this API.

<h2 id="delete">
  Delete a keyword
</h2>

`DELETE /v1/keywords/{id}` stops the keyword for good and frees the plan slot. It answers
**204**.

Without `purge`, it is deliberately not a hard delete: the keyword leaves
`GET /v1/keywords` but stays readable by id with `isActive: false`, and the mentions it
caught stay readable until retention ages them out. You retired a monitor, not the record
of what it found.

With `?purge=true`, every mention the keyword collected is deleted as well, with its
readings and marks. The keyword itself still stays readable by id. Credits are never
refunded either way.

<h2 id="per-source-limits">
  Per-Source limits
</h2>

| Source      | Interval min–max |
| ----------- | ---------------- |
| X           | 60s–86400s       |
| Reddit      | 60s–86400s       |
| Youtube     | 60s–86400s       |
| Bluesky     | 120s–86400s      |
| Hacker News | 120s–86400s      |
| RSS         | 120s–86400s      |
| AI answers  | 21600s–604800s   |
| Vinted      | 15s–86400s       |

Each Source also caps its active searches per Account, see [Limits](/limits#polling-limits).

<Warning>
  These are the **Source** limits, not what your Account can actually do. Your plan applies
  its own floor on top, and the stricter of the two wins: the effective minimum is
  `max(plan minimum, source minimum)`. A Free account's floor is 3600s on every Source.
  Your plan also caps how many keywords you run and how many terms a search carries.

  See [Limits](/limits#plan-entitlements) for the per-plan figures.
</Warning>

<h2 id="billing">
  Billing
</h2>

A keyword is billed for the polling it does. Each check of one search on one Source is a
**tick**: **4 credits** for X, Reddit and Vinted; **5 credits** for Youtube; **1 credit**
for Bluesky, Hacker News and RSS, charged before the poll runs. AI answers charges **100
credits per engine per run**, after each engine answers, and an engine that errors is not
charged. A list of `terms` is one request and one tick, the same as a single term. See
[Credits: Polling](/credits#polling), and use [the estimate](#estimate) to see a
keyword's monthly figure before you create it.

When credits are insufficient, polling pauses with `pausedReason: insufficient_credits`.
It does not resume automatically: call `POST /v1/keywords/{id}/resume` after topping up.

[`backfill: true`](#backfill) charges **one extra tick per Source**, for the one immediate
search it runs. On X that search is charged per tweet returned, 5 credits plus 3 each, at
most 65.

An [agent step](/ai) (`aiStep`) is charged on top of the tick, 1 credit per 25 events read,
and a delivery waits for the reading, up to 120 seconds. The event is delivered either way:
see [Webhooks: Agent step](/webhooks#agent-step).
