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

# Trackers

> Scheduled re-checks that tell you when something actually moved.

A tracker re-runs a list or a search on a schedule and fires a webhook when a condition is met.
It exists so nothing in your stack has to poll.

## Tracking a list

```bash theme={null}
curl -X POST https://api.unif.dev/v1/trackers \
  -H "Authorization: Bearer $UNIF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Competitor revenue swings",
    "source": { "type": "list", "list_id": "lst_01k3m9x7v2q8r4t6y0b1n5d7fa" },
    "schedule": "daily",
    "conditions": [
      { "metric": "revenue", "change": "pct", "over": "1d", "gte": 0.3 },
      { "metric": "revenue", "change": "pct", "over": "1d", "lte": -0.3 }
    ],
    "webhook_id": "whk_01k3m9x7v2q8r4t6y0b1n5d7fa"
  }'
```

Every member of the list is re-checked each cycle. Any member matching any condition fires the
tracker.

## Tracking a search

The other source type re-runs a query, which is how you catch entities you do not know about yet.

```json theme={null}
{
  "name": "New US beauty shops above $100k",
  "source": {
    "type": "search",
    "entity_type": "shop",
    "search": {
      "market": "US",
      "period": { "preset": "last_7d" },
      "filters": {
        "category_ids": ["cat_beauty_personal_care"],
        "revenue": { "gte": 100000 }
      }
    }
  },
  "schedule": "daily"
}
```

With no `conditions`, a search tracker fires on **membership change** — entities that entered or
left the result set since the last run. That is the difference from a list tracker, which watches
metrics on a fixed set.

<Tip>
  Search trackers are how you find competitors before they are competitors. A shop crossing
  \$100k in a category you own is worth knowing about the week it happens, not the quarter.
</Tip>

## Conditions

```json theme={null}
{ "metric": "revenue", "change": "pct", "over": "7d", "gte": 0.3 }
```

<ResponseField name="metric" type="string" required>
  Any metric defined on the tracked entity. See the [metrics reference](/docs/concepts/metrics).
</ResponseField>

<ResponseField name="change" type="string">
  `pct` compares the ratio change against the previous window, `abs` the absolute change. Omit it
  entirely to test the current **level** instead — `{ "metric": "revenue", "lte": 1000 }` fires
  while revenue sits below 1,000.
</ResponseField>

<ResponseField name="over" type="string">
  The comparison window: `1d`, `7d` or `30d`. Defaults to `1d`.
</ResponseField>

<ResponseField name="gte / lte" type="number">
  The bound. Set both to fire inside a band; use two separate conditions to fire outside one.
</ResponseField>

Multiple conditions are **OR** — any match fires. The pair in the first example catches both a 30%
rise and a 30% fall, which is usually what "tell me when this moves" means.

<Warning>
  A percentage change against a small base is noise. Pair a `change` condition with a level
  condition, or filter the underlying list to entities above a revenue floor, or you will get
  paged about a shop that went from $40 to $90.
</Warning>

## Schedules

| Schedule | Runs                                 | Use for                               |
| -------- | ------------------------------------ | ------------------------------------- |
| `hourly` | Every hour                           | Live campaign monitoring, launch days |
| `daily`  | Once per day after the daily refresh | Almost everything                     |
| `weekly` | Once per week                        | Category and market-level trends      |

Running more often than the data refreshes does not make it fresher. Most entities refresh daily —
check `typical_lag_hours` in [coverage](/docs/concepts/coverage) before reaching for `hourly`.

## What a firing delivers

The tracker posts a `tracker.triggered` event to its webhook, carrying the entities that matched
and why.

```json theme={null}
{
  "id": "evt_01k3m9x7v2q8r4t6y0b1n5d7fa",
  "type": "tracker.triggered",
  "created_at": "2026-09-15T06:00:12Z",
  "data": {
    "tracker_id": "trk_01k3m9x7v2q8r4t6y0b1n5d7fa",
    "tracker_name": "Competitor revenue swings",
    "matches": [
      {
        "entity_id": "shp_01k3m9x7v2q8r4t6y0b1n5d7fa",
        "entity_type": "shop",
        "name": "GlowLab",
        "condition": { "metric": "revenue", "change": "pct", "over": "1d", "gte": 0.3 },
        "previous_value": 41200.0,
        "current_value": 58900.5,
        "change": 0.4296
      }
    ]
  }
}
```

A cycle with no matches delivers nothing. Silence means nothing crossed a threshold — not that the
tracker failed. Read `last_run_at` on the tracker if you need to confirm it ran.

## Operating them

<Steps>
  <Step title="One tracker per question">
    A tracker with six unrelated conditions produces events nobody can route. One tracker per
    thing you would actually act on keeps the webhook payload meaningful.
  </Step>

  <Step title="Set a floor">
    Filter the source list or search to entities big enough to matter before adding a percentage
    condition.
  </Step>

  <Step title="Check last_run_at">
    `GET /trackers/{id}` reports `last_run_at` and `next_run_at`. A tracker that has not run is a
    different problem from one that found nothing.
  </Step>

  <Step title="Delete what you stopped reading">
    Trackers consume credits on every cycle whether or not anyone acts on the output.
  </Step>
</Steps>
