Docs
View as Markdown

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.

shell
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.
  • 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.

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:

shell
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:

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
}

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

shell
# 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:

shell
# 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:

FieldMeaning
estimated_credits_per_runThe sum of the target's endpoint prices at list price.
estimated_monthly_creditsThat 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_maxThe same using the dearest price each endpoint can settle at, the most a run can ever cost.
credits_spentThe 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 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 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).

RuleFires 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.

Webhooks#

Events#

EventSent when
monitor.changedA rule fired.
monitor.run_failedA run failed. The body carries the error.
monitor.pausedThe monitor paused itself. reason says why.
testYou 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:

HeaderValue
Content-Typeapplication/json
User-AgentOnekey-Monitors/1.0
Onekey-EventThe event type.
Onekey-Monitor-IdThe monitor id.
Onekey-Delivery-IdThe body's id. It is the same on every retry of one event, so use it to de-duplicate.
Onekey-SignatureSee 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.

# 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

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. 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, 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"}.

Conditionpaused_reasonCharged
The account balance cannot cover the run's worst caseinsufficient_creditsNothing
The creating key's credit limit cannot cover itkey_budget_exceededNothing
The creating key was revokedkey_revokedNothing
You paused it (PATCH {"status": "paused"})userNot applicable
  • Activating is refused with 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.

RouteWhat it does
POST /v1/monitorsCreate. Returns 201 with the monitor and webhook.secret (shown once).
GET /v1/monitorsThe 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}/runsThe last 50 runs, newest first, with delta, matched rules, deliveries and attempts.
GET /v1/monitors/{id}/timeseriesA metric over the monitor's runs (or the list of metrics). Free.
GET /v1/monitors/{id}/exportThe runs or the recorded changes as CSV or JSON. Free.
POST /v1/monitors/{id}/runRun now, billed normally. X-Credits-Remaining is returned when something was charged.
POST /v1/monitors/{id}/webhook/testSend a signed test event now (one attempt) and return the delivery.
POST /v1/monitors/{id}/webhook/rotateIssue 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.

Statuserror.typeWhen
404MONITOR_NOT_FOUNDNo monitor with that id on this account.
409MONITOR_LIMIT_REACHED25 active monitors already, on create or on activating.
409MONITOR_RUN_IN_PROGRESSA manual run while the scheduler is running that monitor.
409WEBHOOK_DISABLEDThe test endpoint, after the webhook was auto-disabled.
422INVALID_INTERVALThe interval is not one of 15m, 1h, 6h, 12h, 24h.
422INVALID_RULEA rule is malformed, or there are more than 10.
422INVALID_TARGETUnknown endpoint or recipe, or a malformed body.
422WEBHOOK_URL_REJECTEDThe 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.

Try it: Webhook signature verifier