# Monitors

A monitor is a saved call that Onekey re-runs on an interval, compares with the previous result, and reports to a webhook you own, signed, when something you asked about changes. It is "tell me when it changes" without polling.

Nothing here is produced by a language model. Change detection is hashing and arithmetic over the data the endpoint served.

```bash
POST https://api.onekey.dev/v1/monitors
x-api-key: YOUR_API_KEY
content-type: application/json
```

## What a monitor is

- **Target.** Any endpoint (`platform`, `endpoint`, `params`), or one of three recipes: `search/multi` (a query on several platforms), `creator/card` (a handle on several platforms) or `lookup` (a public URL, resolved to its endpoint when the monitor is created; the monitor then stores the resolved endpoint plus the original URL). Every endpoint can be monitored.
- **Interval.** `15m`, `1h`, `6h`, `12h` or `24h`. Nothing shorter. Each run lands within 5 % of the interval, so many monitors do not fire in lockstep.
- **Rules.** Which changes deserve a webhook. The default is any change. See [Alert rules](#alert-rules).
- **Webhook.** An `https` URL on a public host. Every event is signed with a per-monitor secret that is shown once.
- **Cap.** At most 25 active monitors per account. Paused ones do not count. Beyond it, create is refused with [`MONITOR_LIMIT_REACHED`](https://onekey.dev/docs/errors.md#monitor-limit-reached).

## Create a monitor

This watches the star count of a repository once an hour, and asks for a webhook when it crosses 251,000 or moves by 1 % or more:

```bash
curl -X POST "https://api.onekey.dev/v1/monitors" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "name": "react stars",
    "target": {"platform": "github", "endpoint": "repo", "params": {"owner": "facebook", "repo": "react"}},
    "interval": "1h",
    "rules": [
      {"on": "threshold", "field": "entity.stats.stars", "op": "gte", "value": 251000},
      {"on": "field_change", "field": "entity.stats.stars", "op": "changed_by_pct", "value": 1}
    ],
    "webhook": {"url": "https://hooks.example.com/onekey"}
  }'
```

The answer is `201` in the standard [response format](https://onekey.dev/docs/response-envelope.md). It is shortened here:

```json
{
  "success": true,
  "data": {
    "id": "mon_...",
    "status": "active",
    "interval": "1h",
    "interval_s": 3600,
    "target": { "platform": "github", "endpoint": "repo", "params": { "owner": "facebook", "repo": "react" } },
    "rules": [ "..." ],
    "webhook": { "url": "https://hooks.example.com/onekey", "secret": "whsec_...", "disabled": false, "consecutive_failures": 0 },
    "estimated_credits_per_run": 1,
    "estimated_monthly_credits": 720,
    "estimated_monthly_credits_max": 720,
    "next_run_at": "...",
    "last_run_at": null,
    "runs": 0,
    "credits_spent": 0
  },
  "metadata": { "request_id": "..." },
  "error": null
}
```

> [!WARN] **Copy `webhook.secret` now.** It is returned once, on create and on [rotation](#endpoints). It cannot be shown again. If you lose it, rotate it.

Other targets use the same body. A search monitor across two platforms:

```bash
# New Hacker News stories and Reddit posts matching a query, checked hourly
curl -X POST "https://api.onekey.dev/v1/monitors" \
  -H "x-api-key: YOUR_API_KEY" -H "content-type: application/json" \
  -d '{
    "target": {"recipe": "search/multi", "params": {"query": "onekey api", "platforms": "hackernews,reddit"}},
    "interval": "1h",
    "rules": [{"on": "new_items"}],
    "webhook": {"url": "https://hooks.example.com/mentions"}
  }'
```

And a URL monitor:

```bash
# A Hacker News story, found from its URL, alerted when its points move by 50 or more between runs
curl -X POST "https://api.onekey.dev/v1/monitors" \
  -H "x-api-key: YOUR_API_KEY" -H "content-type: application/json" \
  -d '{
    "target": {"recipe": "lookup", "params": {"url": "https://news.ycombinator.com/item?id=8863"}},
    "interval": "6h",
    "rules": [{"on": "field_change", "field": "engagement.likes", "op": "gte", "value": 50}],
    "webhook": {"url": "https://hooks.example.com/points"}
  }'
```

A target's parameters are checked when you create the monitor, so a bad one is a free [`INVALID_PARAMS`](https://onekey.dev/docs/errors.md#invalid-params) now rather than a failed first run. An unknown URL is [`UNSUPPORTED_URL`](https://onekey.dev/docs/errors.md#unsupported-url), and a platform with no endpoint for the recipe is [`UNSUPPORTED_PLATFORM`](https://onekey.dev/docs/errors.md#unsupported-platform).

## What it costs

Each run goes through the same pipeline as `GET /v1/{platform}/{endpoint}` (or the recipe), so billing is identical to a normal call:

- The price is the endpoint's price. A [cache hit](https://onekey.dev/docs/caching.md) is free.
- A run that fails is refunded.
- The credit budget of the key that created the monitor applies (see [Per-key credit limits](https://onekey.dev/docs/key-limits.md)).
- Every run appears in the request log and in dashboard usage like any other call.
- A recipe run costs the sum of the legs that succeeded, exactly like [`/v1/search/multi`](https://onekey.dev/docs/search-multi.md).
- A manual run (`POST /v1/monitors/{id}/run`) is billed the same way.

The monitor tells you what it will cost, at creation and on every read:

| Field | Meaning |
| --- | --- |
| `estimated_credits_per_run` | The sum of the target's endpoint prices at list price. |
| `estimated_monthly_credits` | That times the runs in a 30-day month: 2880 at 15m, 720 at 1h, 120 at 6h, 60 at 12h, 30 at 24h. |
| `estimated_monthly_credits_max` | The same using the dearest price each endpoint can settle at, the most a run can ever cost. |
| `credits_spent` | The running total actually charged. |

Real spend is lower whenever a run is a cache hit. For example, the repository monitor above is a 1-credit endpoint hourly: 720 credits a month at most. The `search/multi` monitor is a 1-credit Hacker News search plus a 2-credit Reddit search (3 credits a run, see [Multi-platform search](https://onekey.dev/docs/search-multi.md)) every hour: 2,160 credits a month at list price. Choose the longest interval that still meets your need.

## Change detection

After each successful run Onekey stores a **content hash** (SHA-256 of the canonical JSON of the served `data`) and a **compact snapshot**: one entry per item, with the item's hash and its numeric fields. It never keeps the whole payload between runs. Only `data` counts: `age_s`, `cached`, `request_id` and the derived `computed` and `_warnings` blocks are ignored.

- **Item identity.** The first of these that is present: `content.id`, `entity.id`, `id`, `author.id`, `author.username`, `content.url`, `entity.url`, `url`. Otherwise the item's hash, so an item with no id that changes reads as one removed and one added. A single-record endpoint is one item called `item`. Recipe items are prefixed with their platform (`hackernews:content.id=123`).
- **Limits.** A list keeps its first 500 items, and each item its first 64 numeric fields.
- **First run.** It is the `baseline`. There is nothing to compare with, so no rule can fire. Runs are `baseline`, `changed`, `unchanged` or `failed`.
- **Recipes with a failed leg.** The platforms whose leg failed keep their previous items, and the run's delta lists them in `partial_platforms`. A platform having a bad hour does not read as everything on it being removed.

### The delta

Every run after the first carries a delta:

```json
{
  "added": [],
  "removed": [],
  "changed": [
    { "id": "item", "fields": { "entity.stats.stars": { "from": 250835, "to": 250901, "delta": 66, "pct": 0.0263 } } }
  ],
  "count": { "from": 1, "to": 1 },
  "summary": { "added": 0, "removed": 0, "changed": 1 }
}
```

A changed item whose numeric fields did not move (a renamed title) is listed with empty `fields`. Each list is capped at 100 entries; `"truncated": true` says so, and `summary` keeps the real counts. `pct` is `null` when `from` is 0.

## Alert rules

A list of at most 10 rules, validated at creation ([`INVALID_RULE`](https://onekey.dev/docs/errors.md#invalid-rule) names the problem). A webhook is sent when at least one rule fires. The run's `matched_rules` (and the event) say which rule fired, with the items that made it fire (at most 50 per rule).

| Rule | Fires when |
| --- | --- |
| `{"on": "any_change"}` | The content hash differs from the previous run. This is the default. |
| `{"on": "new_items"}` (`"min_count": n` optional, default 1) | At least n item ids appeared. |
| `{"on": "field_change", "field": "engagement.likes"}` | That numeric field changed on any item. |
| `{"on": "field_change", "field": "engagement.likes", "op": "gte", "value": 10}` | The change (`to - from`) satisfies `op` against `value`. `op` is `gt`, `lt`, `gte` or `lte`. |
| `{"on": "field_change", "field": "engagement.likes", "op": "changed_by_pct", "value": 5}` | The field moved by at least 5 % in either direction. From 0 to anything counts. |
| `{"on": "threshold", "field": "entity.stats.stars", "op": "gte", "value": 1000}` | The new value satisfies `op` against `value` and the previous one did not. |

- **`threshold` is edge-triggered.** You get one alert when the line is crossed, not one per run above it. An item that first appears already above the line counts as crossing. A `threshold` rule needs an `op` and a `value`; `changed_by_pct` is not allowed on it.
- **`field` is a dotted path into the item as it is served**: `engagement.likes`, `entity.stats.stars`, `author.followers`. Read the path from the recorded response on the endpoint's page. Use `$count` for the number of items in the result. A field that is not a number never fires `field_change` or `threshold`; use `any_change` for those.
- Each rule may use only its own keys. A stray key, or a `field` on `any_change`, is [`INVALID_RULE`](https://onekey.dev/docs/errors.md#invalid-rule).

## Webhooks

### Events

| Event | Sent when |
| --- | --- |
| `monitor.changed` | A rule fired. |
| `monitor.run_failed` | A run failed. The body carries the error. |
| `monitor.paused` | The monitor paused itself. `reason` says why. |
| `test` | You called `POST /v1/monitors/{id}/webhook/test`. |

Every event body has this shape (a `monitor.changed` is shown, shortened):

```json
{
  "id": "dlv_...",
  "type": "monitor.changed",
  "created_at": "2026-09-30T10:00:00+00:00",
  "api_version": "v1",
  "monitor": {
    "id": "mon_...", "name": "react stars",
    "target": { "platform": "github", "endpoint": "repo", "params": { "owner": "facebook", "repo": "react" } },
    "interval": "1h", "status": "active", "paused_reason": null
  },
  "run": {
    "id": "run_...", "seq": 12, "trigger": "schedule", "status": "changed",
    "started_at": "...", "finished_at": "...", "credits_charged": 1, "cached": false,
    "content_hash": "...", "previous_hash": "...", "delta": { "...": "..." },
    "matched_rules": [ { "index": 0, "rule": { "on": "any_change" }, "items": [] } ],
    "error": null
  },
  "delta": {
    "added": [], "removed": [],
    "changed": [ { "id": "item", "fields": { "entity.stats.stars": { "from": 250835, "to": 250901, "delta": 66, "pct": 0.0263 } } } ],
    "count": { "from": 1, "to": 1 }, "summary": { "added": 0, "removed": 0, "changed": 1 }
  },
  "matched_rules": [ { "index": 0, "rule": { "on": "any_change" }, "items": [] } ],
  "data": { "entity": { "id": "10270250", "name": "react/react", "stats": { "stars": 250901 } } }
}
```

`data` is the new result exactly as the endpoint served it. A body above **64 KB** drops it (`"data": null, "data_omitted": true`); the delta stays. Only `monitor.changed` carries `delta`, `matched_rules` and `data`. `monitor.paused` adds `reason`, and `test` adds `message`.

Headers on every delivery:

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `Onekey-Monitors/1.0` |
| `Onekey-Event` | The event type. |
| `Onekey-Monitor-Id` | The monitor id. |
| `Onekey-Delivery-Id` | The body's `id`. It is the same on every retry of one event, so use it to de-duplicate. |
| `Onekey-Signature` | See below. |

### Verify the signature

`Onekey-Signature: t=<unix seconds>,v1=<hex>`, where `v1 = HMAC-SHA256(secret, "<t>." + raw request body)` and the secret is the `webhook.secret` from creation (or from the last rotation). To verify:

1. Recompute the digest over the **raw bytes** you received, not over re-serialised JSON.
2. Compare in constant time.
3. Refuse a `t` more than **300 seconds** from your clock (the replay window).

Each retry is signed afresh: a new `t`, the same body.

```python
import hmac, hashlib, time

def verify(secret: str, header: str, body: bytes, tolerance_s: int = 300) -> bool:
    try:
        parts = dict(p.strip().split("=", 1) for p in header.split(","))
        t, given = int(parts["t"]), parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance_s:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(given, expected)

# Flask:   verify(SECRET, request.headers["Onekey-Signature"], request.get_data())
# FastAPI: verify(SECRET, request.headers["Onekey-Signature"], await request.body())
```

```javascript
const crypto = require("node:crypto");

function verify(secret, header, rawBody, toleranceS = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceS) return false;
  const expected = crypto.createHmac("sha256", secret).update(t + ".").update(rawBody).digest("hex");
  const a = Buffer.from(parts.v1, "hex"), b = Buffer.from(expected, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: app.post("/hook", express.raw({ type: "*/*" }), (req, res) => {
//   if (!verify(SECRET, req.get("Onekey-Signature"), req.body)) return res.sendStatus(400);
//   res.sendStatus(200);
// });
```

```bash
# Recompute the digest for a saved body. T is the t= value from the header,
# body.json is the raw body exactly as received. Compare with the v1= value by eye.
T=1759226400
printf '%s.' "$T" | cat - body.json | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1
```

> [!TIP] Read the body as bytes before your framework parses it. A JSON parser followed by a re-serialiser changes spacing and key order, and the signature will not match.

### Delivery, retries and auto-disable

- **Success** is any `2xx` answer. The answer body is ignored (at most 64 KB of it is read). A `3xx` is a failure: redirects are never followed. Any other status, a connection error, or no answer within **5 seconds** is a failure.
- **Retries.** After a failed attempt the next one waits 1 minute, 5 minutes, 30 minutes, 2 hours, then 12 hours (each with 20 % jitter). That is at most **6 attempts** per event, after which the delivery is `failed`. Deliveries and their attempts are on the run: `GET /v1/monitors/{id}/runs` shows `deliveries[].attempts[]` with `attempt`, `at`, `status_code`, `error` and `ok`, and `next_attempt_at`.
- **Auto-disable.** After **10 consecutive failed attempts** on a monitor its webhook is switched off. Pending deliveries are dropped and no new ones are queued. The monitor shows `webhook.disabled: true` and a `webhook_disabled` notice, and the test endpoint answers [`WEBHOOK_DISABLED`](https://onekey.dev/docs/errors.md#webhook-disabled). **Runs continue**: you still see `changed` runs and their deltas through the API. A successful attempt resets the counter.
- **Re-enable** with `PATCH /v1/monitors/{id}` and `{"webhook": {"url": "..."}}`. The same URL is fine; it also resets the counter.
- **Ordering** is not guaranteed across events. Use `run.seq` and `Onekey-Delivery-Id`.

Send a signed `test` event any time with `POST /v1/monitors/{id}/webhook/test`. It makes one attempt, no retries, and returns the delivery.

### Webhook URL rules (SSRF)

A webhook URL is refused with [`WEBHOOK_URL_REJECTED`](https://onekey.dev/docs/errors.md#webhook-url-rejected), at creation and on `PATCH`, unless it is `https` on port 443 to a public host. That means:

- No `http`, no user info in the URL, no other port.
- No private IP literal, and no host encoded as a number.
- No internal names (`localhost`, `*.local`, `*.internal`).
- The host must resolve **only** to public addresses. Loopback, private ranges (`10/8`, `172.16/12`, `192.168/16`), link-local and cloud-metadata addresses, shared address space, multicast, reserved ranges and IPv4-mapped IPv6 are all rejected. A host with one private answer among public ones is rejected.

At delivery time the host is resolved again with the same rules and the connection goes to that exact address, with the hostname kept for the `Host` header and TLS, so a DNS record that changes between the check and the connect cannot redirect the request. Redirects are not followed. To test locally, put a public HTTPS tunnel or a request-catcher service in front of your machine; a local address is refused.

## When a monitor pauses

When a run cannot be paid for, the monitor pauses itself and tells you. A paused monitor sends `monitor.paused` (and a `monitor.run_failed` for the run that could not pay) and is not run again until you `PATCH {"status": "active"}`.

| Condition | `paused_reason` | Charged |
| --- | --- | --- |
| The account balance cannot cover the run's worst case | `insufficient_credits` | Nothing |
| The creating key's credit limit cannot cover it | `key_budget_exceeded` | Nothing |
| The creating key was revoked | `key_revoked` | Nothing |
| You paused it (`PATCH {"status": "paused"}`) | `user` | Not applicable |

- Activating is refused with [`MONITOR_LIMIT_REACHED`](https://onekey.dev/docs/errors.md#monitor-limit-reached) if it would exceed 25 active monitors.
- A run that fails for any other reason (the source platform errored, the record was not found) is a `failed` run. It is refunded and reported with `monitor.run_failed`, but the monitor **stays active** and tries again at the next interval.
- A paused monitor can still be run by hand. It stays paused.
- Monitors belong to the key that created them. Revoking that key pauses its monitors at their next run, so create monitors with a key you intend to keep.

## Time series and export

Monitors keep the numbers they saw, so you can chart them or hand them to a spreadsheet without calling anything again. Both routes are free and read-only.

`GET /v1/monitors/{id}/timeseries` rebuilds a metric over the monitor's runs from its stored snapshot and each run's recorded delta. Without `metric` it lists what exists (`metrics`, `items`). With `metric` (a numeric field path such as `engagement.likes`, or `$count` for the number of items) it returns one point per run, oldest first: `{seq, run_id, at, status, value, items_with_value, complete}` plus `stats` (first, last, min, max, change, change_pct). Pick one stored item with `item` (its key, as listed), or fold the metric over all items with `agg` (`sum`, `avg`, `min`, `max`, `count`; default `sum`). A monitor with one item needs no `item`. `limit` (1 to 1,000, default 200) is how many runs are considered.

A failed run measured nothing, so its point has `value: null` and `status: failed`. Values from before the stored snapshot are rebuilt by undoing each run's delta, which has two honest limits: an item a run **removed** has no stored numbers for older runs, and a delta that hit its cap (`truncated`) loses detail. A point that needed numbers the monitor never stored says `complete: false`; values are never invented.

`GET /v1/monitors/{id}/export` returns the runs (`kind=runs`, default: one row per run with status, credits, hash, items added, removed and changed, counts and matched rules) or every recorded change (`kind=changes`: one row per added item, removed item and changed numeric field, with `from`, `to`, `delta` and `pct`) as `format=csv` (default, a download) or `format=json`. Text cells that a spreadsheet would read as a formula are prefixed with an apostrophe.

## Endpoints

All routes use your API key and the standard response format.

| Route | What it does |
| --- | --- |
| `POST /v1/monitors` | Create. Returns `201` with the monitor and `webhook.secret` (shown once). |
| `GET /v1/monitors` | The account's monitors. |
| `GET /v1/monitors/{id}` | One monitor. |
| `PATCH /v1/monitors/{id}` | Change `name`, `interval`, `rules`, `webhook: {url}` (which re-enables a disabled webhook) or `status` (`active` or `paused`). |
| `DELETE /v1/monitors/{id}` | Delete. Pending deliveries are dropped. |
| `GET /v1/monitors/{id}/runs` | The last 50 runs, newest first, with delta, matched rules, deliveries and attempts. |
| `GET /v1/monitors/{id}/timeseries` | A metric over the monitor's runs (or the list of metrics). Free. |
| `GET /v1/monitors/{id}/export` | The runs or the recorded changes as CSV or JSON. Free. |
| `POST /v1/monitors/{id}/run` | Run now, billed normally. `X-Credits-Remaining` is returned when something was charged. |
| `POST /v1/monitors/{id}/webhook/test` | Send a signed `test` event now (one attempt) and return the delivery. |
| `POST /v1/monitors/{id}/webhook/rotate` | Issue a new signing secret, shown once. Deliveries from then on use it. |

Every endpoint carries `features.monitors: true` in `GET /v1/platforms` (and `x-features.monitors` in [/openapi.json](https://onekey.dev/openapi.json)) to say it can be monitored. The `/v1/monitors` operations in the OpenAPI file describe the intervals, events, signature scheme, retry schedule and rule grammar.

## Errors

Every error is in [Errors](https://onekey.dev/docs/errors.md) with what to do about it.

| Status | `error.type` | When |
| --- | --- | --- |
| 404 | [`MONITOR_NOT_FOUND`](https://onekey.dev/docs/errors.md#monitor-not-found) | No monitor with that id on this account. |
| 409 | [`MONITOR_LIMIT_REACHED`](https://onekey.dev/docs/errors.md#monitor-limit-reached) | 25 active monitors already, on create or on activating. |
| 409 | [`MONITOR_RUN_IN_PROGRESS`](https://onekey.dev/docs/errors.md#monitor-run-in-progress) | A manual run while the scheduler is running that monitor. |
| 409 | [`WEBHOOK_DISABLED`](https://onekey.dev/docs/errors.md#webhook-disabled) | The test endpoint, after the webhook was auto-disabled. |
| 422 | [`INVALID_INTERVAL`](https://onekey.dev/docs/errors.md#invalid-interval) | The interval is not one of `15m`, `1h`, `6h`, `12h`, `24h`. |
| 422 | [`INVALID_RULE`](https://onekey.dev/docs/errors.md#invalid-rule) | A rule is malformed, or there are more than 10. |
| 422 | [`INVALID_TARGET`](https://onekey.dev/docs/errors.md#invalid-target) | Unknown endpoint or recipe, or a malformed body. |
| 422 | [`WEBHOOK_URL_REJECTED`](https://onekey.dev/docs/errors.md#webhook-url-rejected) | The webhook URL is not `https` on a public host. |

A target's bad parameters are the usual [`INVALID_PARAMS`](https://onekey.dev/docs/errors.md#invalid-params), an unsupported recipe platform is [`UNSUPPORTED_PLATFORM`](https://onekey.dev/docs/errors.md#unsupported-platform) and an unknown lookup URL is [`UNSUPPORTED_URL`](https://onekey.dev/docs/errors.md#unsupported-url). All of these are refused before anything runs and cost nothing.

## Related

- [Credits & pricing](https://onekey.dev/docs/credits.md) and [Caching](https://onekey.dev/docs/caching.md): what a run costs, and why a cache hit is free.
- [URL lookup](https://onekey.dev/docs/lookup.md) and [Multi-platform search](https://onekey.dev/docs/search-multi.md): the two recipes a monitor can use.
- [MCP server](https://onekey.dev/docs/mcp.md) and [Integrations](https://onekey.dev/docs/integrations.md): agent and workflow tools. Monitors are managed through the REST API, not through the MCP tools.
