Docs / Reachability API / Endpoints / List watched targets
List watched targets
One row per watched target, newest probe first. `reachable` is strictly “the last probe got a 200”, and it is not a claim about whether the site is up — read `failure_class` first. `blocked_by_bot_protection` means the target's edge answered our client a challenge page instead of the site; two of the six targets served today are in that position, and the answer has been measured flipping with nothing changing but the HTTP version negotiated. `robots_disallowed` means no request was made at all, which is why `http_status` is null there. Every row carries `vantage`, because the same URL has answered `403` to a datacenter address and `200` to a residential one on the same day. And `consecutive_failures` is the field that turns a row into a finding: one refusal is noise, and sixty-one consecutive ones are a fact about a relationship with that site. `order` takes `ingested_at` and nothing else, and it decides very little — one probe pass writes every row in a single batch, so they share an `ingested_at` and the whole collection fits in one page at any `limit` this API offers.
Query parameters
| Parameter | Type | Description |
|---|---|---|
watchlist |
string | The named set of targets to read — `middle-corridor` is the first one. Open rather than a closed set, because registering a watchlist is a row rather than a release, so an unknown value answers an empty page rather than a 400. |
vantage |
string | Where the probe was made from, e.g. `hetzner-nbg1`. Narrowing on it is how you compare two vantages' answers for the same target — which is the comparison this collection exists to make routine. |
reachable |
boolean | `true` for targets whose last probe got a 200, `false` for the rest. Omit it for both. **`reachable=false` is not a list of sites that are down** — pair it with `failure_class` before drawing that conclusion. |
failure_class |
string | One of `http_error`, `blocked_by_bot_protection`, `tls_error`, `dns_error`, `timeout`, `connection_error`, `robots_disallowed`. Closed, unlike `watchlist` and `vantage`, because this one is our own judgement and a typo in it would mean you believed you had filtered. `?failure_class=blocked_by_bot_protection` is how you ask which of these are our problem rather than theirs. |
limit |
integer | Items per page, 1–100. Defaults to 25. |
cursor |
string | Opaque cursor from a previous response's meta.next. Do not construct one. |
order |
string | Sort field: `ingested_at`, each optionally prefixed with `-` to reverse. Default is `-ingested_at`. Rows with no value for the sort column always sort LAST in either direction, so paging never leads with undated rows. |
source |
string | Restrict to ONE scraper source, by its stem — `abb`, not `ABB` or `abb-bank.az`. Not a list: `?source=a,b` and a repeated `?source=` do not select two sources. On `/v1/jobs` an unknown stem is a `400` naming it, and `source_type` is the way to select several sources at once. `GET /v1/sources` lists every stem. |
Request
curl -G https://api.softon.dev/v1/reachability \
-H "Authorization: Bearer $SOFTON_KEY" \
-d watchlist=middle-corridor -d vantage=hetzner-nbg1 -d reachable=false
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.softon.dev/v1/reachability", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("SOFTON_KEY")) q := req.URL.Query() q.Set("watchlist", "middle-corridor") q.Set("vantage", "hetzner-nbg1") q.Set("reachable", "false") req.URL.RawQuery = q.Encode() res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() var page struct { Data []Reachability `json:"data"` Meta struct{ Next *string `json:"next"` } `json:"meta"` } if err := json.NewDecoder(res.Body).Decode(&page); err != nil { log.Fatal(err) } // page.Meta.Next → send it back as ?cursor= for the following page
import json, os, urllib.parse, urllib.request url = "https://api.softon.dev/v1/reachability" + "?" + urllib.parse.urlencode({ "watchlist": "middle-corridor", "vantage": "hetzner-nbg1", "reachable": "false", }) req = urllib.request.Request(url, headers={ "Authorization": "Bearer " + os.environ["SOFTON_KEY"], }) page = json.load(urllib.request.urlopen(req)) # page["meta"]["next"] → send it back as ?cursor= for the following page
const url = new URL("https://api.softon.dev/v1/reachability"); url.searchParams.set("watchlist", "middle-corridor"); url.searchParams.set("vantage", "hetzner-nbg1"); url.searchParams.set("reachable", "false"); const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SOFTON_KEY}` }, }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const { data, meta } = await res.json(); // meta.next → send it back as ?cursor= for the following page
Response
The envelope is identical on every softon.dev API: data, meta, error. Only the shape inside data changes per dataset — see the response envelope.
{
"data": [
{
"id": "softonbot:az-ady-corp",
"source": "softonbot",
"target_id": "az-ady-corp",
"url": "https://corp.ady.az/en",
"watchlist": "middle-corridor",
"vantage": "hetzner-nbg1",
"reachable": false,
"http_status": 403,
"final_url": "https://corp.ady.az/en",
"redirected": false,
"failure_class": "blocked_by_bot_protection",
"error": "Blocked by the site's bot protection (a challenge page, not a refusal)",
"response_ms": 159,
"robots_allowed": true,
"checked_at": "2026-09-15T07:08:02Z",
"last_reachable_at": null,
"first_failed_at": "2026-07-16T05:20:41Z",
"consecutive_failures": 61,
"ingested_at": "2026-09-15T07:08:09.214703Z"
}
],
"meta": { "count": 1, "next": "eyJvIjoyfQ", "request_id": "req_9Fv3" },
"error": null
}
Fields of each item in data[]
| Field | Type | Description |
|---|---|---|
id |
string | `<source>:<target_id>` — the prober and the target it asked. Stable for the life of the target, and what `/v1/reachability/{id}` takes. |
source |
string | Which prober made the request. **This is the one collection here where `source` is softon and not an upstream publisher**: a row is a record of a request this platform made, so the source is the client that made it. A second prober at a second vantage is a second source, and a second row for the same target. |
target_id |
string | The stable key for one watched URL. Chosen when the target is registered and never derived from the URL, so a target that changes host keeps the history in the three fields at the bottom of this table. |
url |
string | The URL that was requested, exactly as registered. |
watchlist |
string | The named set of targets this one belongs to, and the value `?watchlist=` takes. `middle-corridor` is the first one. Open rather than a closed set — a new watchlist is a row, not a release — so an unknown value answers an empty page rather than a 400. |
vantage |
string | **Where the request was made from**, and the field that stops this row being read as a verdict on the site. `hetzner-nbg1` is a datacenter address in Nuremberg, which is this platform's ingestion box. The answer genuinely depends on where you ask from: the same URL has been measured answering `403` to a datacenter address and `200` to a residential one on the same day. A reachability figure quoted without the vantage attached is a measurement of a pair reported as a property of one half of it — which is also why the prober is carried as `source`, so that a second client probing the same target is a second row rather than a silent redefinition of this one. |
reachable |
boolean | Whether the last probe got an HTTP **200**. Strictly 200: a `204` or a `206` reads as false here, deliberately, because that is what the pipeline this replaces means by the word. **`false` is not a claim that the site is down.** Two of the six targets served today sit behind a bot-protection edge whose answer is decided by the request rather than by the site: measured with one client, one user agent, one address and one minute, both answered `403` on every HTTP/2 sample and `200` on every HTTP/1.1 one, and one of them is not stable even within a single client. So read `failure_class` before turning this into a count, and `consecutive_failures` before turning one row into a finding. A single `blocked_by_bot_protection` is noise; sixty-one consecutive ones are a fact about a relationship with that site. |
http_status |
integer · nullable | The final status after redirects. Null when no HTTP response was obtained at all: a name that did not resolve, a handshake that was refused, a timeout, or a target whose robots.txt means we did not ask. Always present as a key, null where inapplicable. |
final_url |
string · nullable | Where the request ended up after redirects. Null when there was no response. |
redirected |
boolean | Whether the request moved before answering. False when there was no response. |
failure_class |
string · nullable | Why it was not a 200, from a closed set, and **null exactly when `reachable` is true** — the database refuses either half without the other, so there is no such row as one that failed without saying why. `http_error` — the host answered, with something that is not 200. `blocked_by_bot_protection` — the host answered 403 with a bot-protection challenge page. **Our client failed a check; the site is up.** It is separate from `http_error` because it is a fact about the request rather than about the target, and separate from an outage because collapsing the two publishes a finding about a site that is serving fine. `tls_error` — the certificate or the handshake was refused; a browser would refuse it too. `dns_error` — the name did not resolve. `timeout` — nothing came back inside the budget. `connection_error` — the connection failed or was reset below HTTP. `robots_disallowed` — **we did not ask.** The target's robots.txt refuses this crawler for this path, so `http_status` is null because no request was made, not because one failed. |
error |
string · nullable | A short plain sentence, at most 200 characters, or null. `"TLS certificate expired"`, not a stack trace. **Treat it as user-facing copy**, because on the page this dataset was built for it is rendered verbatim to the public. The pipeline it replaces writes `str(exception)` into the equivalent field, which is how a visitor to a transport-analysis page came to read a urllib3 connection-pool repr complete with a CPython source-file line number. Null rather than truncated when a sentence cannot be vouched for: a shortened exception repr is still an exception repr, and the meaning is in `failure_class`, which is never null on a failing row. |
response_ms |
integer · nullable | How long the request took, in milliseconds. Null when nothing came back. One measurement from one vantage on a shared box — useful for noticing a target getting slower, not a latency SLA. |
robots_allowed |
boolean | What the target's own `robots.txt` says about this crawler fetching this path. Nothing in the pipeline this replaces checks robots at all. Fails **open** when robots.txt cannot be fetched — a 404 or a timeout there is not a refusal. When it is false no request is made, and `failure_class` is `robots_disallowed`. |
checked_at |
timestamp | When the probe ran, on the prober's clock. **Date a finding by this**, not by `ingested_at`: the pipeline this replaces stamps its rows with the date of the build that read them, so a row could claim it was checked on a day nothing was. |
last_reachable_at |
timestamp · nullable | The last probe that got a 200. Null for a target that has never answered one. |
first_failed_at |
timestamp · nullable | When the current run of failures began. Null while the target is reachable, and reset the moment it answers 200 again. |
consecutive_failures |
integer | How many probes in a row have not got a 200; 0 while the target is reachable. This and the two fields above are the value this collection has that a one-shot check cannot: "this target has been refusing us for sixty-one straight probes" is an editorial signal, and "it refused us once, today" is not. They are accumulated where the row is stored, so they survive a redeploy and the prober never has to remember anything. Counts **probes**, not days — the same number at the daily cadence this runs at, and named for the fact so that changing the cadence cannot silently redefine it. All three reset if `vantage` changes, because a counter spanning two vantages answers neither question. |
ingested_at |
timestamp | When this platform stored the observation. Differs from `checked_at` by the time the batch spent in flight; compare the two to see whether probe runs are landing at all. |
Try it
Send the request to see a response.