What a monitor is#
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 when something you asked about changes. It is "tell me when it changes" without polling. Nothing here uses a language model: change detection is hashing and arithmetic over the data the endpoint served. Any of the 1,995 endpoints can be monitored, and so can three recipes: a multi-platform search, a creator card, or a public URL.
| Interval | 15m, 1h, 6h, 12h or 24h. Nothing shorter. Each run lands within 5 % of the interval. |
| Cap | 25 active monitors per account. Paused ones do not count. |
| Webhook | An https URL on port 443 to a public host, signed with a per-monitor secret shown once. |
| Cost | The endpoint's price per run, with the estimate on the monitor itself. Cache hits are free and failed runs are refunded. |
Create one#
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"}
}'Replace the example webhook URL with an HTTPS endpoint you control: the host in the example does not exist, and a URL that does not resolve to a public address is refused with WEBHOOK_URL_REJECTED (checked on the dev API, along with an invalid interval, which is a free INVALID_INTERVAL). The answer is 201 with the monitor and webhook.secret, shown once, so copy it then. It also carries the cost estimate: estimated_credits_per_run, estimated_monthly_credits (720 for a 1-credit endpoint hourly: 2,880 at 15m, 120 at 6h, 30 at 24h) and estimated_monthly_credits_max. Choose the longest interval that still meets your need.
Rules: what deserves a webhook#
Up to 10 rules per monitor, validated when you create it. A webhook is sent when at least one fires, and the event says which rule fired and for which items.
| Rule | Fires when |
|---|---|
any_change (default) | The content hash of the served data differs from the previous run. |
new_items | At least n item ids appeared (min_count, default 1). |
field_change | A numeric field changed on any item, optionally by at least an amount (gt, lt, gte, lte) or by a percentage (changed_by_pct). |
threshold | A numeric field crosses a line. Edge-triggered: one alert when crossed, not one per run above it. |
The first run is a baseline: nothing to compare with, so no rule fires. Every later run carries a delta of what was added, removed and changed, with from, to, delta and percentage per numeric field; each list is capped at 100 entries and says when it is truncated. A recipe run with a failed leg keeps that platform's previous items and lists it in partial_platforms, so a platform having a bad hour does not read as everything on it being removed. Rule and delta details are on the monitors docs.
Webhooks you can trust#
Every delivery is signed. The header is Onekey-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>." + raw request body with your secret. Verify over the raw bytes, compare in constant time, and refuse a timestamp more than 300 seconds off your clock:
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)Paste a real delivery into the free webhook signature verifier to check it in your browser and see why a check fails. It runs locally: your secret is never sent anywhere.
- Success is any
2xxwithin 5 seconds. A3xxis a failure: redirects are never followed. - Retries: 1 minute, 5 minutes, 30 minutes, 2 hours, then 12 hours (each with 20 % jitter), at most 6 attempts per event.
Onekey-Delivery-Idis the same on every retry, so use it to de-duplicate. - Auto-disable: after 10 consecutive failed attempts the webhook is switched off. Runs continue and their deltas stay available through the API. Re-enable it with
PATCH /v1/monitors/{id}; a successful attempt resets the counter. - A body over 64 KB drops
dataand keeps the delta.
Webhook URL rules (SSRF)#
A webhook URL is refused with WEBHOOK_URL_REJECTED, at creation and on update, unless it is https on port 443 to a public host. No http, no user info, no other port, no private or numeric IP literals, and no internal names (localhost, *.local, *.internal). The host must resolve only to public addresses, so loopback, private ranges, link-local, cloud-metadata and reserved addresses are all rejected, and 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, so a DNS change between the check and the connect cannot redirect the request. To test locally, put a public HTTPS tunnel or a request catcher in front of your machine.
When a monitor pauses#
If a run cannot be paid for (account balance, the creating key's credit limit, or a revoked key) the monitor pauses itself, charges nothing, and sends monitor.paused with the reason. A run that fails for any other reason (the source errored, the record was not found) is refunded, reported as monitor.run_failed, and the monitor stays active. Monitors belong to the key that created them, so create them with a key you intend to keep.
Limits, honestly#
- The shortest interval is 15 minutes. This is scheduled polling, not a live stream.
- A list keeps its first 500 items and each item its first 64 numeric fields for comparison.
- Non-numeric fields never fire
field_changeorthreshold; useany_changefor those. - Monitors are managed through the REST API, not through the MCP tools, and reports cannot be monitored yet.
- Delivery ordering is not guaranteed across events; use
run.seqand the delivery id.