RAILGUN liveness API
Anon runs a monitor that checks the Railgun services its wallet depends on: the PPOI node, Anon's own indexer, Subsquid, broadcasters and Waku. It rechecks them about every 30 seconds, chain by chain, and publishes the result as one small JSON document at a public URL. Anyone can read it: wallets, dashboards, status pages and scripts. There is no API key and no account.
GET https://api.anon.inc/api/v1/railgun/liveness
This is Anon's independent view from its own monitor. It is not official Railgun status, and it is never a "safe to transact" signal. What it does not tell you lists the limits.
Beta: this API is in beta. Fields, enums and the shape of the response may change without notice. There is no notice period and no deprecation or sunset commitment. Write your client to ignore unknown fields and values. See Beta status.
| Property | Value |
|---|---|
| Base URL | https://api.anon.inc |
| Authentication | None |
| CORS | Any origin, no credentials (GET, HEAD, OPTIONS) |
| Status | Beta: may change without notice (Beta status) |
| Format | JSON, schemaVersion 1 (JSON Schema) |
| Rechecked | About every 30 seconds, less often for a source that keeps failing |
| Cache | 15 seconds in browsers, 30 seconds in shared caches |
A request never triggers a probe; every answer comes from the stored snapshot.
Quick start
curl -i https://api.anon.inc/api/v1/railgun/liveness
The response carries the report and its cache headers:
HTTP/2 200
content-type: application/json; charset=utf-8
cache-control: public, max-age=15, s-maxage=30, stale-while-revalidate=30, stale-if-error=300
etag: "79d583eb71a37058"
access-control-allow-origin: *
access-control-expose-headers: ETag
Send the ETag back with If-None-Match to ask whether anything changed. If not, the answer is 304 Not Modified with no body:
curl -i https://api.anon.inc/api/v1/railgun/liveness \
-H 'If-None-Match: "79d583eb71a37058"'
From a browser, a plain fetch works from any origin:
const response = await fetch("https://api.anon.inc/api/v1/railgun/liveness", {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(8000),
});
if (!response.ok) throw new Error(`liveness: HTTP ${response.status}`);
const report = await response.json();
A row is only as good as its own expiry time. Judge each row against your clock before you show it. A row with no usable evidence is unknown, and a row past its lifetime is stale. Neither is ever shown as healthy:
const FIVE_MINUTES = 5 * 60 * 1000;
const CLOCK_SKEW = 30 * 1000;
const ACTIONABLE = new Set(["current", "catching-up", "degraded", "unavailable"]);
function effectiveStatus(check, now = Date.now()) {
// Neither of these asserts anything that could go stale.
if (check.status === "unmonitored" || check.status === "unknown") return check.status;
// A status this client does not know is not evidence.
if (!ACTIONABLE.has(check.status)) return "unknown";
const observed = Date.parse(check.observedAt);
const expires = Date.parse(check.expiresAt);
// Missing, unparseable or inconsistent timestamps are not evidence either.
if (!Number.isFinite(observed) || !Number.isFinite(expires)) return "unknown";
if (observed > now + CLOCK_SKEW || expires <= observed) return "unknown";
const stale = expires <= now || now - observed >= FIVE_MINUTES;
return stale ? "stale" : check.status;
}
// Decide for yourself which chains count as mainnets.
const MAINNETS = new Set([1, 42161, 137, 56]);
const ppoi = report.checks.filter((c) => c.service === "ppoi" && MAINNETS.has(c.chainId));
console.log(ppoi.map((c) => `${c.chainId}: ${effectiveStatus(c)}`));
Endpoint
GET https://api.anon.inc/api/v1/railgun/liveness
- No authentication and no parameters. Any query string is ignored.
HEADreturns the headers only.OPTIONSanswers CORS preflights.- The body is
application/json; charset=utf-8and a few kilobytes long. - One response covers every service on every chain. Fetch it once and filter.
Example response
A trimmed report with five checks, with illustrative values. A real one lists every service on every chain.
{
"schemaVersion": 1,
"generatedAt": "2026-10-03T12:00:05.418Z",
"checks": [
{
"service": "waku",
"chainId": null,
"status": "current",
"reason": "responding",
"observedAt": "2026-10-03T11:59:40.993Z",
"lastSuccessAt": "2026-10-03T11:59:40.993Z",
"expiresAt": "2026-10-03T12:02:10.993Z",
"metrics": { "peers": 2 }
},
{
"service": "ppoi",
"chainId": 1,
"status": "current",
"reason": "synced",
"observedAt": "2026-10-03T11:59:52.206Z",
"lastSuccessAt": "2026-10-03T11:59:52.206Z",
"expiresAt": "2026-10-03T12:02:22.206Z",
"metrics": { "position": 139296, "head": 139296 }
},
{
"service": "broadcasters",
"chainId": 1,
"status": "current",
"reason": "responding",
"observedAt": "2026-10-03T11:59:40.993Z",
"lastSuccessAt": "2026-10-03T11:59:40.993Z",
"expiresAt": "2026-10-03T12:02:10.993Z",
"metrics": { "responding": 82, "monitored": 85 }
},
{
"service": "indexer",
"chainId": 1,
"status": "catching-up",
"reason": "behind",
"observedAt": "2026-10-03T11:59:58.731Z",
"lastSuccessAt": "2026-10-03T11:59:58.731Z",
"expiresAt": "2026-10-03T12:02:28.731Z",
"metrics": { "position": 26115307, "head": 26115332 }
},
{
"service": "subsquid",
"chainId": 137,
"status": "unavailable",
"reason": "unreachable",
"observedAt": "2026-10-03T12:00:01.093Z",
"lastSuccessAt": "2026-10-03T11:58:09.870Z",
"expiresAt": "2026-10-03T12:02:31.093Z",
"metrics": {}
}
]
}
- The
wakurow is the shared Waku check, so itschainIdisnull. It iscurrent, andpeersis the number of peers of Anon's monitored Waku connection. - The
broadcastersrow iscurrent: 82 of the 85 broadcasters Anon monitors on that chain answered.monitoredis the real denominator. - The
ppoirow is current.positionis the PPOI node's validated TXID index andheadis the same node's current index. - The
indexerrow is behind its reference by 25 blocks, more than the allowance, so it reportscatching-up. - The
subsquidrow isunavailable: Anon's monitor got no usable answer on three probes in a row.observedAtis the latest failed probe, whilelastSuccessAtkeeps the last time the service answered. A failure carries no metrics. - Checks are currently ordered
waku,ppoi,broadcasters,indexer,subsquid, each by ascendingchainId. Do not rely on the order.
Fields
Report
| Field | Type | Meaning |
|---|---|---|
schemaVersion |
integer | 1 today. It may change while the API is in beta. |
generatedAt |
timestamp | When the snapshot was assembled, not when it was served. It advances at least every 30 seconds while the monitor is alive. |
checks |
array | One object per service and scope. Each service and chainId pair appears at most once. |
Check
| Field | Type | Meaning |
|---|---|---|
service |
string | ppoi, broadcasters, waku, indexer or subsquid today. More may appear. |
chainId |
integer or null |
Positive chain ID. null for waku today, which is one shared check. |
status |
string | What to do with the row. See Status. |
reason |
string | Why the row has that status. New values may appear. See Reason. |
observedAt |
timestamp or null |
When the latest probe completed. A failed probe is an observation too. null if never checked or unmonitored. |
lastSuccessAt |
timestamp or null |
When the service itself last answered and was compared. Kept across later failures. null if it never did. |
expiresAt |
timestamp or null |
When this observation stops meaning anything. observedAt plus the row lifetime, 150 seconds at launch. Present for every actionable status (current, catching-up, degraded, unavailable). |
metrics |
object | Measurements. Always present, with each key omitted when unknown. |
Timestamps are UTC with millisecond precision and a literal Z: YYYY-MM-DDTHH:mm:ss.sssZ.
Metrics
| Key | Meaning |
|---|---|
position |
The service's own position: the validated TXID index for ppoi, the indexed block for indexer and subsquid. |
head |
What it is compared with: the PPOI node's current TXID index, or the chain's latest block minus the service's own confirmation depth. |
responding |
How many broadcasters answered. Set together with monitored, and never above it. |
monitored |
How many broadcasters were actually checked. This is the real denominator. |
peers |
Connected peers of the monitored Waku connection. |
New keys may appear. Ignore keys you do not know. Every metric listed here is a non-negative integer no larger than 2^53 − 1. A metric that is unknown is omitted. It is never filled with 0, so never read a missing key as zero. An unmonitored row has "metrics": {}.
position can be larger than head: the reference head is read on its own schedule and can be up to a minute old. Clamp the lag at zero.
Status and reason
Status
These are the statuses today. This is not a closed set: values may be added or renamed without notice while the API is in beta. Map any status you do not recognize to unknown for that row.
| Status | Meaning | What to do |
|---|---|---|
current |
The service answered and is caught up with its reference, within the allowance. | Show it as answered and caught up, as of observedAt. |
catching-up |
The service answered but is behind its reference by more than the allowance. | Expect lag. This is not an outage. |
degraded |
The service answers but is seriously impaired: far behind for a sustained time, or only partly covering what it should. | Warn. Do not rely on it for time-sensitive work. |
unavailable |
Anon's monitor got no usable answer on three probes in a row. | Treat it as not responding. |
unknown |
No usable evidence yet, or the evidence cannot be compared safely. | Neither good nor bad news. Do not show a green state. |
unmonitored |
Anon does not monitor this check. | Not a failure. Show it as not covered. |
A row that is past its expiresAt is stale whatever its status says, and the row of a service that is down can reach you that way. See Freshness and caching.
Reason
| Reason | Comes with | Meaning |
|---|---|---|
synced |
current |
The position is within the allowance of its head, or the PPOI node has validated up to its own current index. |
behind |
catching-up, degraded |
The position trails its head by more than the allowance. |
responding |
current |
For services without a position to compare: Waku and broadcasters. |
partial-coverage |
degraded |
Only some of the monitored members answer. Used for broadcasters. |
unreachable |
unknown, unavailable |
A probe could not reach or understand the service. Up to two failures in a row give unknown; three give unavailable. |
not-configured |
unmonitored |
Anon does not monitor this check. |
no-observation |
unknown |
Nothing has been observed yet, or the service answered but has nothing for this chain. |
incompatible-reference |
unknown |
The service answered but its numbers cannot be compared safely. For example the PPOI node does not carry the required list, or the chain head used as the reference is too old. |
The reasons are not a closed set either. Write your client so that a reason it does not know is handled by acting on status alone. Waku and broadcaster monitoring may still bring new reasons.
Thresholds
These are the launch values. They are monitor policy rather than part of the schema, so Anon may re-tune them without changing the schema version.
| Rule | Value |
|---|---|
| Recheck | A source that answers is rechecked about every 30 seconds, with up to 3 seconds of jitter. A source that keeps failing is rechecked less often: the wait doubles with each consecutive failure, 30 seconds, then 60 seconds, then a cap of 2 minutes. |
| Row lifetime | 150 seconds: expiresAt is observedAt plus 150 seconds. |
| Outage | unavailable after 3 consecutive failed probes. The first two give unknown. |
| Lag allowance | indexer and subsquid are current while the lag is at most the larger of 10 blocks and about 2 minutes of blocks. |
| Degraded | A lag of 30 minutes or more on 2 consecutive evaluations. |
| PPOI behind | The node's validated index trails its own current index continuously for 45 seconds. |
| Reference head | The chain head used as the reference must be at most 60 seconds old, or the row is unknown with incompatible-reference. |
Services and chains
| Service | What Anon checks | position |
head |
|---|---|---|---|
ppoi |
The configured PPOI node must carry the required list for the network. Its validated TXID index is compared with the same node's current index. | Validated TXID index | The node's current TXID index |
indexer |
Anon's own indexer's last processed block, compared with the chain head minus the indexer's confirmation depth. | Last processed block | Reference head net of the confirmation depth |
subsquid |
The configured Subsquid deployment's indexed height, compared with the chain head minus the squid's own confirmation depth. | Indexed height | Reference head net of the confirmation depth |
broadcasters |
Per chain: how many of a bounded set of broadcasters answer. Monitored since 2026-10-04. | — | — |
waku |
One shared check of Anon's monitored Waku connection. Monitored since 2026-10-04. | — | — |
- Chains. The monitored mainnets are Ethereum (
1), Arbitrum (42161), Polygon (137) and BNB Chain (56). Sepolia (11155111) is a test network, is never part of a mainnet rollup, and has no Subsquid deployment, so itssubsquidrow is unmonitored. More chains may appear without notice. - Waku and broadcasters. Both have been monitored since 2026-10-04 and report a real status, with the reason
respondingorpartial-coverageand thepeers,respondingandmonitoredmetrics. Newreasonvalues andmetricskeys may still appear. Handle that as described in Beta status. - Reference heads. The chain heads come from public RPC endpoints. They have no row of their own.
- Who runs what. The PPOI node and the Subsquid deployment are not operated by Anon. An outage there shows up as Anon's reading of the configured deployment.
For the services themselves, see PPOI proxy, Broadcaster network and Wallet syncing.
Freshness and caching
The monitor rechecks each source that answers about every 30 seconds and stores a snapshot. Every request is answered from that snapshot in memory. The response carries:
Cache-Control: public, max-age=15, s-maxage=30, stale-while-revalidate=30, stale-if-error=300
| Directive | Effect |
|---|---|
max-age=15 |
A browser reuses its copy for 15 seconds. |
s-maxage=30 |
A shared cache, such as the CDN, reuses its copy for 30 seconds: one recheck. |
stale-while-revalidate=30 |
A cache may serve a copy up to 30 seconds past that while it fetches a fresh one. |
stale-if-error=300 |
If the origin errors, a cache may keep serving its copy for up to 5 minutes. |
How old can a row be? For a source that answers, a row can be up to about 44 seconds old at the origin: the 30 second cadence, 3 seconds of jitter, a probe that takes up to 10 seconds, and a second to publish. Caches add up to 60 seconds on top (30 of s-maxage and 30 of stale-while-revalidate). A healthy row therefore reaches you at most about 104 seconds after it was observed, inside its 150 second lifetime, so it should not arrive already expired.
A source that keeps failing is rechecked less often. After each consecutive failure the monitor waits twice as long before it tries that source again: 30 seconds, then 60 seconds, then a cap of 2 minutes. With jitter and a probe that can take up to 10 seconds, the row of a source that is down can be up to about 133 seconds old at the origin, and about 193 seconds old by the time a cache hands it to you. That is past its 150 second lifetime, so an unavailable row can reach you already expired. It then reads as stale rather than unavailable. Treat a stale row as "no recent reading", never as healthy. A service that is down can look exactly like that.
Freshness belongs to each row, not to HTTP. Treat a row as stale when its expiresAt is at or before now on your clock, or when its observedAt is 5 minutes old or more, whatever its status says. Treat a row as unknown when a timestamp its status needs is missing or cannot be read, when observedAt is more than about 30 seconds in the future, or when expiresAt is not after observedAt. A 200, a 304 or a cache hit never renews a row.
A stalled monitor. While it is alive, generatedAt advances at least every 30 seconds, even if every probe hangs. If the monitor stops, the stored report stays as it was and its rows age out through expiresAt. The same happens with a copy served through stale-if-error. Both look stale, never healthy.
ETag and 304. The ETag is a strong validator: a quoted 16-character hash of the body. The monitor publishes whenever a probe completes and at least every 30 seconds, and generatedAt is part of the body, so the ETag changes at least that often. If-None-Match is compared weakly, a W/ prefix is ignored, and * matches. A 304 carries the ETag, Cache-Control and CORS headers and no body.
HTTP status codes and errors
| Status | When | Notes |
|---|---|---|
200 |
The snapshot. | Also when services are unhealthy: a 200 means the report is readable, not that the services are healthy. |
304 |
If-None-Match matched. |
No body. |
204 |
An OPTIONS preflight. |
See CORS. |
405 |
Any other method. | Allow: GET, HEAD, OPTIONS. Not cached. |
503 |
The answering server has not loaded its first snapshot yet. | Short-lived, for example just after a deploy. Not cached. Retry with backoff. |
404 |
Any other path. | Answered by the gateway. |
429 |
You are above the rate limit in Fair use: more than 120 requests per minute per IP on this path. | Comes from the CDN (Cloudflare error 1015) rather than the API. Lasts about 60 seconds, with Retry-After of about 60. Cached hits count too. |
On this route, errors are {"error": "<message>"} with Content-Type: application/json; charset=utf-8 and Cache-Control: no-store:
{"error":"liveness report unavailable"}
Branch on the HTTP status code, never on the message text. The gateway's 404 ({"message":"Not Found"}) and a CDN 429 use other bodies: the 429 is a Cloudflare text/plain page.
In a browser, a 429 is not readable. The CDN's 429 page carries no Access-Control-Allow-Origin, so the browser blocks it and fetch rejects with a TypeError, the same as for any network or CORS failure. Your code never sees the status or Retry-After. Treat any network failure like a 429 or 503: back off, keep the last good report, and let its rows expire through expiresAt.
After the first snapshot is loaded, failures never produce a 503. The server keeps serving the last snapshot, and its rows age out through expiresAt.
CORS
The report is public and has no per-caller content, so every response allows every origin:
| Header | Value |
|---|---|
Access-Control-Allow-Origin |
* |
Access-Control-Expose-Headers |
ETag |
This is sent on every answer (200, 304, 204, 405 and 503), whatever the request's Origin. There is no Access-Control-Allow-Credentials. The response sends no Vary: Origin, but the CDN still keeps a separate cached copy for each Origin request header value, so the first request from a new origin may be a cache MISS. The query string is ignored: a request with any ?x= reuses the same copy.
A preflight (OPTIONS) is answered with 204 and:
| Header | Value |
|---|---|
Access-Control-Allow-Methods |
GET, HEAD, OPTIONS |
Access-Control-Allow-Headers |
Accept, Content-Type, If-None-Match |
Access-Control-Max-Age |
86400 |
A plain fetch with an Accept header is a simple request and needs no preflight. A script that sets If-None-Match itself does, and that header is allowed.
Fair use
- There is no key and no SLA. This is a best-effort public feed in beta.
- Respect
Cache-Control, and useETagwithIf-None-Matchso an unchanged report costs a304. - Poll no faster than every 15 to 30 seconds. The data changes about every 30 seconds, so faster polling only returns cached copies.
- Wallets should fetch it when a user opens a screen that depends on it, rather than on a timer.
- Servers that poll should send a descriptive
User-Agentthat says who you are, for examplemy-wallet/1.4 (+https://example.com/contact). - Requests above 120 per minute per IP on this path are rate limited: they get
429for about 60 seconds. Cached hits count too. Back off on429and on5xx. - In a browser, the
429surfaces as a CORS or network error, not a readable response. Treat any network failure like a429or503: back off, keep the last good report, and let its rows expire throughexpiresAt(see HTTP status codes and errors).
Beta status
This API is in beta, and Anon makes no stability promise for it yet.
- It may change without notice. Fields, enums and the shape of the response may be added, renamed, retyped or removed with no notice period. Anon does not commit to a deprecation or sunset process, or to
DeprecationandSunsetheaders. schemaVersionmay change./api/v1is the gateway's deployment-wide route version, not a version of this endpoint. The endpoint's ownschemaVersionis in the body, currently1, and it may change too. The JSON Schema is permissive and describes today's response. It is not a promise.- The host is
api.anon.incfor now. An alias such asapi.railscan.iomay come later. No date is promised. - Changes are recorded on a best-effort basis in the changelog below.
How to write a client that survives that:
- Ignore unknown fields. Ignore unknown object fields at every level: the report, each check and
metrics. - Do not rely on closed sets. Map a
statusyou do not recognize tounknownfor that row only. Treat areasonyou do not recognize as "no detail" and act onstatus. Ignore rows whoseserviceorchainIdyou do not use. Ignoremetricskeys you do not know. - Fail one row, not the whole report. One row you cannot read should not blank every other row.
- Check
schemaVersion. If it is not a value you know, the shape may have changed. Treat the report as unreadable until you have updated, rather than guessing.
What holds today. This is how the endpoint behaves now, so handle it correctly, but it is not promised:
- Metrics. Every check has a
metricsobject, and each metric key is omitted when unknown. An unmonitored row ismetrics: {}. Never read a missing metric as0. - One row per scope. Each service and
chainIdpair appears at most once.wakuis the only shared row, withchainId: null. Do not sum rows blindly. Decide mainnet membership with your own chain allowlist: Sepolia (11155111) is never part of mainnet rollups. - Freshness is per row. Use the rule in Freshness and caching.
expiresAtisnulltoday only forunknownandunmonitoredrows. - Numbers. Clamp lag at zero because
positionmay exceedhead.respondingis never abovemonitored. All metrics are non-negative safe integers.
What it does not tell you
- It is not official Railgun status. It is Anon's monitor, reading the services Anon is configured to use, from Anon's infrastructure. Your view of those services can differ.
- It is not a "safe to transact" signal.
ppoibeingcurrentdoes not mean a given shield is spendable or a given proof is valid. It means the configured PPOI node has validated up to its own current index. It says nothing about any one wallet's PPOI validity, compliance or spendable balance. - A degraded broadcasters row is a partial reading. It means some monitored broadcasters did not answer. Other broadcasters or delivery methods may still work. Check the method and its privacy limits in Broadcaster network.
- Waku peers are not delivery.
peerscounts connections of Anon's monitored connection. It does not show that a message reaches a broadcaster and comes back. - Indexer and Subsquid rows are not your wallet's sync. They say whether those services are caught up with their chains. Your own wallet sync is separate. See Wallet syncing.
Privacy
A request to this endpoint carries nothing about your wallet: no address and no account. Anon and the CDN in front of it still see your IP address and User-Agent, like any HTTPS request. A wallet that polls on a timer shows that IP's interest in Railgun at a 30 second rhythm. If that matters to you, poll lazily, or fetch the report from your own server and serve your users from that cache.
Changelog
2026-10-04
Waku and broadcasters are now monitored, so their rows report a real status instead of unmonitored. The rate limit is enforced: more than 120 requests per minute per IP on this path get 429 for about 60 seconds.
Version 1 (beta)
Initial release, in beta: no stability promise. schemaVersion 1. Services ppoi, indexer, subsquid, broadcasters and waku, on Ethereum, Arbitrum, Polygon, BNB Chain and Sepolia. Waku and broadcasters are listed but unmonitored at launch. Each check lives 150 seconds, the monitor rechecks a source that answers about every 30 seconds (a source that keeps failing less often), and the response is cacheable for 30 seconds in shared caches.