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.

GET https://api.softon.dev/v1/reachability Copy

Query parameters

ParameterTypeDescription
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

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[]

FieldTypeDescription
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

v1 · stable
Send the request to see a response.