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.
POST https://api.onekey.dev/v1/monitors
x-api-key: YOUR_API_KEY
content-type: application/jsonWhat 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) orlookup(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,12hor24h. 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.
- Webhook. An
httpsURL 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.
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:
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. It is shortened here:
{
"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
}Other targets use the same body. A search monitor across two platforms:
# 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:
# 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 now rather than a failed first run. An unknown URL is UNSUPPORTED_URL, and a platform with no endpoint for the recipe is 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 is free.
- A run that fails is refunded.
- The credit budget of the key that created the monitor applies (see Per-key credit limits).
- 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. - 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) 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 calleditem. 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 arebaseline,changed,unchangedorfailed. - 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:
{
"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 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. |
thresholdis 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. Athresholdrule needs anopand avalue;changed_by_pctis not allowed on it.fieldis 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$countfor the number of items in the result. A field that is not a number never firesfield_changeorthreshold; useany_changefor those.- Each rule may use only its own keys. A stray key, or a
fieldonany_change, isINVALID_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):
{
"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:
- Recompute the digest over the raw bytes you received, not over re-serialised JSON.
- Compare in constant time.
- Refuse a
tmore than 300 seconds from your clock (the replay window).
Each retry is signed afresh: a new t, the same body.
# 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' ' -f1Delivery, retries and auto-disable#
- Success is any
2xxanswer. The answer body is ignored (at most 64 KB of it is read). A3xxis 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}/runsshowsdeliveries[].attempts[]withattempt,at,status_code,errorandok, andnext_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: trueand awebhook_disablednotice, and the test endpoint answersWEBHOOK_DISABLED. Runs continue: you still seechangedruns 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.seqandOnekey-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, 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_REACHEDif 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
failedrun. It is refunded and reported withmonitor.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) 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 with what to do about it.
| Status | error.type | When |
|---|---|---|
| 404 | MONITOR_NOT_FOUND | No monitor with that id on this account. |
| 409 | MONITOR_LIMIT_REACHED | 25 active monitors already, on create or on activating. |
| 409 | MONITOR_RUN_IN_PROGRESS | A manual run while the scheduler is running that monitor. |
| 409 | WEBHOOK_DISABLED | The test endpoint, after the webhook was auto-disabled. |
| 422 | INVALID_INTERVAL | The interval is not one of 15m, 1h, 6h, 12h, 24h. |
| 422 | INVALID_RULE | A rule is malformed, or there are more than 10. |
| 422 | INVALID_TARGET | Unknown endpoint or recipe, or a malformed body. |
| 422 | WEBHOOK_URL_REJECTED | The webhook URL is not https on a public host. |
A target's bad parameters are the usual INVALID_PARAMS, an unsupported recipe platform is UNSUPPORTED_PLATFORM and an unknown lookup URL is UNSUPPORTED_URL. All of these are refused before anything runs and cost nothing.
Related#
- Credits & pricing and Caching: what a run costs, and why a cache hit is free.
- URL lookup and Multi-platform search: the two recipes a monitor can use.
- MCP server and Integrations: agent and workflow tools. Monitors are managed through the REST API, not through the MCP tools.
Try it: Webhook signature verifier