Everything the node knows, over HTTP
One Fastify process serves the watchtower, the feed relay, the radar and the bundle relay. The trust half of it is free and unauthenticated. The speed half is metered, because that is the half that pays for the other one.
Field names, types and nullability are transcribed from apps/api/src/server.ts and are exact. The values inside each example are illustrative — they are not a live reading, and no figure on this page should be quoted as one. Live numbers are on /status and /watch. Where the web app’s own client (lib/api.ts) types a narrower subset of a response, the payload here is the wider thing the server actually sends.
Base URL
This site reads from the base below. Paths are versioned under /v1; /health deliberately is not, because a health check that can 404 on a version bump is not a health check.
http://127.0.0.1:8788
Every response, including every error, is application/json. CORS permits GET, POST and OPTIONS. Request bodies are capped at 2 MiB and WebSocket frames at 1 MiB. Nothing here needs a browser or an SDK — the whole surface is curl-able, which is a property we intend to keep.
What is free
Attestations, assertions, daily roots, the streak, chain status, watchtower state, node metadata and health are free, unauthenticated, and will stay that way. A watchtower behind a paywall is not a watchtower — a finding nobody can read protects nobody, and a verdict you have to buy is not evidence, it is a subscription.
Those routes carry no key check and no token bucket in the process at all. If this node disappears the record survives it: each day’s root is committed to AttestationRegistry on Robinhood Chain and mirrored to CheckpointAnchor on Ethereum, and any attestation you already hold stays verifiable against those roots with no API in the loop. See verify without us.
What a key buys is on the speed layer only: real-time rather than delayed feed frames, the radar socket, simulation against head state, and the bundle relay. No tier buys queue position, and none can. The full list of what we cannot do →
Authentication
A key may be presented three ways. The server takes the first one it finds, in this order, and stops looking.
| Order | Where | Form | Notes |
|---|---|---|---|
| 1 | Authorization | Bearer <key> | Matched case-insensitively after trimming. A header that is present but not in Bearer form falls through to the next source rather than failing the request. |
| 2 | X-API-Key | <key> | Any non-empty string. The simplest option for a server-side client. |
| 3 | ?key= | <key> | Query string. This is the only route that works for a browser WebSocket, which cannot set headers — at the cost of the key appearing in logs and referrers. |
A key looks like verd_<tier>_<24 random bytes, base64url>. We store its SHA-256 hash and the first 16 characters as a prefix for support, and nothing else. The key itself is shown exactly once, at issuance. We cannot show it to you again — not as policy, but because we do not have it. A leaked database yields no usable credential.
An unknown, revoked or expired key resolves to the public tier instead of returning 401. A credential that lapses at 03:00 should cost you latency, not take your process down. Two ways to notice: the feed hello frame echoes the tier it resolved you to, and a paid route answers 402 with actual: "public". Usage accounting (requestCount, lastSeenAt) is written fire-and-forget so a slow database can never add latency to a feed request.
# All three resolve to the same identity.
curl -H 'Authorization: Bearer verd_pro_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
BASE/v1/radar?limit=5
curl -H 'X-API-Key: verd_pro_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
BASE/v1/radar?limit=5
wscat -c 'BASE/v1/feed/ws?key=verd_pro_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'Substitute the base URL above for BASE; for the socket, the scheme is ws:// or wss://.
Rate limits
Three routes are metered, each against its own named token bucket: radar, simulate and bundle. The bucket key is the API key id, or the client IP when the request is anonymous. Capacity is max(rate, 5) tokens and it refills at rate per second, so a short burst above the steady rate is allowed and a sustained one is not. Every other route is unmetered in the process.
The limiter is in-process, per node, on purpose: the paid tiers are served from machines where a round trip to a shared Redis would cost more than the request it is protecting. A horizontally scaled deployment therefore multiplies the effective ceiling by the node count, and we would rather say so than imply a global counter that does not exist.
| Tier | feed delay | radar req/s | simulate req/s | bundles req/s | Radar socket | Archive reads |
|---|---|---|---|---|---|---|
| Public | 2,000 ms | 5 | no access | no access | no | no |
| Pro | real time | 100 | 10 | 1 | yes | no |
| Turbo | real time | 1,000 | 100 | 10 | yes | yes |
| Colocated | real time | 10,000 | 1,000 | 100 | yes | yes |
Per-second figures for simulate and bundles are the published per-minute allowances divided by sixty, floored at one, which is exactly how the server derives them. The row values above are read from /v1/meta at render time.
Trust layer
Free, unauthenticated, unmetered. Nine routes.
/health
free · no keyLiveness plus one line per embedded service. ok is true only when the feed is connected (or explicitly disabled on this node) and the watchtower is holding no error. Service values are operational, degraded, outage or disabled.
rate limit none
{
"ok": true,
"uptimeSeconds": 419377,
"services": {
"watchtower": "operational",
"feed": "operational",
"radar": "operational",
"relay": "operational"
}
}/v1/meta
free · no keyStatic-per-boot description of the network, the rollup contracts, the ecosystem addresses we watch, the probed RPC capabilities, the validator allowlist, the force inclusion window and the tier table. Read contract addresses from here rather than hard-coding them; deployed.* is null for anything not configured on the node you are querying.
rate limit none
{
"network": {
"name": "Robinhood Chain",
"chainId": 4663,
"parentChainId": 1,
"rpc": "https://rpc.mainnet.chain.robinhood.com",
"feed": "wss://feed.mainnet.chain.robinhood.com",
"explorer": "https://robinhoodchain.blockscout.com",
"contracts": {
"rollup": "0x23A19d23e89166adedbDcB432518AB01e4272D94",
"sequencerInbox": "0xBd0D173EEb87D57A09521c24388a12789F33ba96",
"bridge": "0xDf8755334ce7A73cCF6b581C02eA649AE3E864b3",
"inbox": "0x1A07cc4BD17E0118BdB54D70990D2158AbAD7a2D",
"challengeManager": "0x6f38FC91105Fc9a43931DcA33450ab3315E3D4Fa",
"stakeToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"deployedAt": "24994238"
}
},
"ecosystem": {
"weth": "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73",
"usdg": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
"permit2": "0x000000000022D473030F116dDEE9F6B43aC78BA3",
"multicall3": "0xcA11bde05977b3631167028862bE2a173976CA11",
"create2Deployer": "0x4e59b44847b379578588920cA78FbF26c0B4956C",
"createX": "0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed",
"uniswapV2Factory": "0x8bcEaA40B9AcdfAedF85AdF4FF01F5Ad6517937f",
"uniswapV3Factory": "0x1f7d7550B1b028f7571E69A784071F0205FD2EfA",
"uniswapV4PoolManager": "0x8366a39CC670B4001A1121B8F6A443A643e40951",
"universalRouter": "0x8876789976dEcBfCbBbe364623C63652db8C0904",
"swapRouter02": "0xcaf681a66d020601342297493863e78c959e5cb2"
},
"capabilities": {
"sendRawTransactionConditional": true,
"simulateV1": true,
"sendRawTransactionSync": true,
"subscribe": false,
"txpool": false,
"debugTrace": false,
"createAccessList": false,
"priorityFeeMarket": false,
"clientVersion": "nitro/v3.11.3-rc.4",
"arbOsVersion": 61
},
"permissionedValidation": {
"whitelistEnforced": true,
"knownValidators": [
"0xa0A1D8cd6f867a583a39232a477ff231aFD8eC4C",
"0x992D8d50548fCcCaF11147A9a692c496011C8Aa0"
],
"activeStakerCount": 1,
"l2beatClassification": "Other"
},
"forceInclusion": {
"delayBlocks": "28800",
"delaySeconds": "345600",
"note": "Censorship backstop only. Four days against a 100 ms block time is not an execution route."
},
"challengeWindowSeconds": 549816,
"deployed": {
"attestationRegistry": null,
"checkpointAnchor": null,
"executorFactory": null,
"settlement": null,
"verd": null,
"accessVault": null,
"bondVault": null
},
"tiers": [
{
"id": "public",
"name": "Public",
"stakeRequired": 0,
"monthlyUsd": null,
"feedDelayMs": 2000,
"rpcRateLimit": 5,
"maxSubscriptions": 1,
"simulateRpm": 0,
"bundleRpm": 0,
"archiveAccess": false,
"radarAccess": false,
"crossConnect": false,
"blurb": "Delayed feed and the full attestation history. Free, forever, no key required."
}
// pro, turbo and colocated follow, in that order, with the same fields
]
}maxSubscriptions is published because it is part of the tier definition. The current build does not enforce a per-key socket count, and this page will say so until it does.
/v1/status
free · no keyThe chain as the node last read it: L2 head, observed block time, gas price, L1 head, rollup parameters and inbox counters. Served from an in-memory snapshot that the status service refreshes on a timer, so updatedAt (milliseconds) tells you how stale it is. Returns 503 status_unavailable before the first refresh completes, or when the status service is not running in this process.
rate limit none
{
"updatedAt": 1784917229118,
"network": {
"name": "Robinhood Chain",
"chainId": 4663,
"parentChainId": 1,
"explorer": "https://robinhoodchain.blockscout.com"
},
"l2": {
"head": "19001111",
"blockTimeMs": 76,
"gasPriceGwei": "0.010000"
},
"l1": { "head": "23617884" },
"rollup": {
"address": "0x23A19d23e89166adedbDcB432518AB01e4272D94",
"wasmModuleRoot": "0xc10cd7ec6acaf1c441a3f6bd0900ad20f15855ba775a96f1939118cbc629dc97",
"confirmPeriodBlocks": "45818",
"challengeWindowSeconds": 549816,
"latestConfirmed": "0x2ad4c8e15f70b93a6c81de429057fb3ce6a84d1027bf395e8c60a4712dbf5e93",
"baseStakeWei": "1000000000000000000",
"stakeToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"validatorWhitelistEnforced": true,
"knownValidators": [
"0xa0A1D8cd6f867a583a39232a477ff231aFD8eC4C",
"0x992D8d50548fCcCaF11147A9a692c496011C8Aa0"
],
"activeStakerCount": 1
},
"inbox": {
"sequencerInbox": "0xBd0D173EEb87D57A09521c24388a12789F33ba96",
"batchCount": "148277",
"delayedMessageCount": "31904",
"totalDelayedMessagesRead": "31904",
"delayedInboxDepth": "0",
"forceInclusionDelaySeconds": "345600"
},
"errors": []
}Each read is isolated: one unreachable endpoint nulls a single field and appends a line to errors rather than blanking the snapshot.
/v1/watchtower
free · no keyWhat the verifier itself is doing. enabled is false when the watchtower is not running in this process, in which case only challengeWindowSeconds, canChallengeOnChain and whyNot are present. canChallengeOnChain is hard-coded false and always will be while the allowlist stands — it is a constant in the source, not a computed field, so it cannot drift into an accidental claim.
rate limit none
{
"enabled": true,
"running": true,
"readOnly": false,
"attester": "0x4B9c7fA1e0D3c58b2A6f81E4D07c93Ba52f610Ae",
"lastScannedBlock": "23617881",
"l1Head": "23617884",
"totalAssertions": 5431,
"matched": 5427,
"mismatched": 0,
"pending": 4,
"lastVerifiedAt": 1784916059,
"medianLatencyMs": 41986,
"wasmModuleRoot": "0xc10cd7ec6acaf1c441a3f6bd0900ad20f15855ba775a96f1939118cbc629dc97",
"wasmModuleRootChanged": false,
"lastError": null,
"challengeWindowSeconds": 549816,
"canChallengeOnChain": false,
"whyNot": "validatorWhitelistDisabled() is false on the rollup, so EdgeChallengeManager rejects any caller outside the allowlist."
}readOnly is true when the node holds no attester key: it still verifies every assertion, it just cannot sign the result. medianLatencyMs is measured from the parent-chain block timestamp of the assertion to the completed check.
/v1/attestations
free · no keyThe ledger. One row per assertion the rollup has posted, joined to our verdict, newest parent block first. total counts rows matching the filter, not the page.
rate limit none
| Param | Type | Default | Notes |
|---|---|---|---|
| limit | integer | 50 | Between 1 and 500. Coerced from the string, so ?limit=100 is fine. |
| offset | integer | 0 | Zero or greater. |
| verdict | enum | unset | One of match, mismatch, pending, lagging, unavailable. Anything else is a 400 with the zod issue attached. |
{
"total": 5431,
"limit": 1,
"offset": 0,
"items": [
{
"assertionHash": "0x7c1f0d9a4e6b23c58af0d1e37b9c4a2508f6d31b8e0a7c95d24f1b60ae38c7d2",
"parentAssertionHash": "0x2ad4c8e15f70b93a6c81de429057fb3ce6a84d1027bf395e8c60a4712dbf5e93",
"verdict": "match",
"reasons": [],
"checks": [
{
"name": "machine_status",
"ok": true,
"expected": "FINISHED",
"actual": "FINISHED"
},
{
"name": "wasm_module_root",
"ok": true,
"expected": "0xc10cd7ec6acaf1c441a3f6bd0900ad20f15855ba775a96f1939118cbc629dc97",
"actual": "0xc10cd7ec6acaf1c441a3f6bd0900ad20f15855ba775a96f1939118cbc629dc97",
"advisory": true
},
{
"name": "block_hash_known",
"ok": true,
"expected": "0x9e4b7a0c15d3f286be5170ca94d8e3b27f60195acd48e2b7031f6d85ca297b40",
"actual": "0x9e4b7a0c15d3f286be5170ca94d8e3b27f60195acd48e2b7031f6d85ca297b40"
},
{
"name": "send_root",
"ok": true,
"expected": "0x3f81c62d0ae749b5138026fcad95e70b4c8d13af62957e0b8dc41f36a507e9b8",
"actual": "0x3f81c62d0ae749b5138026fcad95e70b4c8d13af62957e0b8dc41f36a507e9b8"
},
{
"name": "block_canonical",
"ok": true,
"expected": "block 18994312 == 0x9e4b7a0c15d3f286be5170ca94d8e3b27f60195acd48e2b7031f6d85ca297b40",
"actual": "0x9e4b7a0c15d3f286be5170ca94d8e3b27f60195acd48e2b7031f6d85ca297b40"
}
],
"claimed": {
"blockHash": "0x9e4b7a0c15d3f286be5170ca94d8e3b27f60195acd48e2b7031f6d85ca297b40",
"sendRoot": "0x3f81c62d0ae749b5138026fcad95e70b4c8d13af62957e0b8dc41f36a507e9b8",
"inboxPosition": "31902",
"machineStatus": 2
},
"local": {
"blockNumber": "18994312",
"sendRoot": "0x3f81c62d0ae749b5138026fcad95e70b4c8d13af62957e0b8dc41f36a507e9b8"
},
"parent": {
"blockNumber": "23617612",
"txHash": "0xc6b2148f79d0e35a2f9b70c418ad6e35920fb7c1d84e0a635f27bd910c4ae8f7",
"timestamp": 1784916017,
"explorerUrl": "https://etherscan.io/tx/0xc6b2148f79d0e35a2f9b70c418ad6e35920fb7c1d84e0a635f27bd910c4ae8f7"
},
"l2ExplorerUrl": "https://robinhoodchain.blockscout.com/block/18994312",
"wasmModuleRoot": "0xc10cd7ec6acaf1c441a3f6bd0900ad20f15855ba775a96f1939118cbc629dc97",
"latencyMs": 41986,
"checkedAt": 1784916059,
"signature": "0x1f8b0c4d7a2e6395b0d18f47c25a90e3b6741dfa08c25e3971b40adf62c8593e4a7d10b93fc625ea8017d43b9e2f508c61ad7f394be0c2851d6a93f70be4185c1b",
"signer": "0x4B9c7fA1e0D3c58b2A6f81E4D07c93Ba52f610Ae",
"digest": "0x51a6c930f74be2081dc35a97e6b4108f2d7c93b05e461fa8720d3c96b85e017a",
"day": 20658,
"confirmedAt": null
}
]
}Verdict vocabulary: match and mismatch are decisions; lagging means our node was behind the assertion and cannot yet judge it; unavailable means an L2 read failed; pending means the row was inserted and not yet checked. Only match and mismatch are ever signed or included in a daily tree. wasm_module_root is advisory: a changed execution root is reported loudly but never flips a verdict on its own, because it does not make the state claim false.
/v1/attestations/:hash
free · no keyOne attestation by assertion hash. Same object as a list item, plus a proof field once the day has been committed, so you can check Merkle inclusion without asking us a second time. :hash must match ^0x[0-9a-fA-F]{64}$ or the request is a 400; an unknown hash is a 404.
rate limit none
{
// ... all fields from the list item above, then:
"proof": {
"day": 20658,
"root": "0x8d2f0be4719ca35608df21b74e9c0a3f5628d1740bfe93a2c6015d84be37f0a9",
"leafIndex": 17,
"siblings": [
"0x6b1c04f7e9d2385a70bc41e6d5920f83ac7146bd035e91c2874af06d3be1275c",
"0xa38e5d10c7b4926f0813da75be2c40917fd63e8b52014ca9736d0f8bc1e4a205",
"0x0f742be9d13c85a06fb27e410dc9538614ad70f2b93ce68154a2d07fb6c39e81"
]
}
}The tree is rebuilt per request from that day’s match and mismatch rows ordered by assertion hash, so leafIndex is stable for a committed day. Leaves are OpenZeppelin-style double hashes and sibling pairs are sorted before hashing, which is why the proof carries no direction bits.
/v1/assertions
free · no keyRaw assertion rows as read from the BoLD rollup on Ethereum, before any verdict is attached — the parent-chain facts only. Useful if you want to run your own comparison and treat our opinion as one input among several.
rate limit none
| Param | Type | Default | Notes |
|---|---|---|---|
| limit | integer | 50 | 1 to 500. |
| offset | integer | 0 | Zero or greater. |
| verdict | enum | unset | Accepted by the shared query schema and then ignored on this route: assertions carry no verdict. Filter on /v1/attestations instead. |
{
"items": [
{
"assertionHash": "0x7c1f0d9a4e6b23c58af0d1e37b9c4a2508f6d31b8e0a7c95d24f1b60ae38c7d2",
"parentAssertionHash": "0x2ad4c8e15f70b93a6c81de429057fb3ce6a84d1027bf395e8c60a4712dbf5e93",
"parentBlockNumber": "23617612",
"parentBlockHash": "0xb1740c6ef392a58d0c17be49d2a53810fc7b26e05d419a3f860cb7d24e01f593",
"parentTxHash": "0xc6b2148f79d0e35a2f9b70c418ad6e35920fb7c1d84e0a635f27bd910c4ae8f7",
"parentLogIndex": 214,
"parentTimestamp": 1784916017,
"poster": "0xa0A1D8cd6f867a583a39232a477ff231aFD8eC4C",
"claimedBlockHash": "0x9e4b7a0c15d3f286be5170ca94d8e3b27f60195acd48e2b7031f6d85ca297b40",
"claimedSendRoot": "0x3f81c62d0ae749b5138026fcad95e70b4c8d13af62957e0b8dc41f36a507e9b8",
"inboxPosition": "31902",
"positionInMessage": "0",
"machineStatus": 2,
"beforeBlockHash": "0x5c03a9e7412bd680f37ca19e46d0582bf7139ea4c608d25b71fa3e09d4c81762",
"beforeSendRoot": "0xd4907cb1e5382f60ab74195c0e3fd826a71b09453ef8c26d180a9b74f3ce6015",
"wasmModuleRoot": "0xc10cd7ec6acaf1c441a3f6bd0900ad20f15855ba775a96f1939118cbc629dc97",
"requiredStake": "1000000000000000000",
"challengeManager": "0x6f38FC91105Fc9a43931DcA33450ab3315E3D4Fa",
"confirmPeriodBlocks": "45818",
"inboxMaxCount": "31904",
"confirmedAt": null,
"confirmedTxHash": null,
"createdAt": "2026-07-24T18:00:59.412Z"
}
]
}machineStatus 2 is FINISHED; 3 is ERRORED, which is a halt condition rather than a failure of the state claim. confirmedAt is filled in when AssertionConfirmed lands on Ethereum, roughly 6.36 days later. Every numeric chain quantity is a decimal string, because these are uint256 columns and a JSON number would silently truncate them.
/v1/roots
free · no keyThe daily Merkle roots, newest first, capped at 400 rows. No parameters. Each root covers one UTC day of signed attestations and is written to AttestationRegistry on Robinhood Chain and mirrored to CheckpointAnchor on Ethereum. day is days since the Unix epoch; date is the same value rendered.
rate limit none
{
"items": [
{
"day": 20657,
"root": "0x8d2f0be4719ca35608df21b74e9c0a3f5628d1740bfe93a2c6015d84be37f0a9",
"leafCount": 45,
"mismatchCount": 0,
"registryTxHash": "0x74be0d3196ca582f0e71bd4a3c980f61e5d27a4b8039fc1e625ad70b93f4c8e1",
"registrySubmittedAt": 1784848112,
"anchorTxHash": "0x0a63b9de17f4c2508b73e1da950c48f62b7de034195ac86e2f70bd41c539087a",
"anchorSubmittedAt": 1784848361,
"createdAt": "2026-07-24T00:02:41.884Z",
"date": "2026-07-23"
}
]
}A null registryTxHash or anchorTxHash means the root is computed but not yet committed on that chain. Until it is committed, an inclusion proof against it proves only that we said so.
/v1/streak
free · no keyConsecutive complete days with zero discrepancies, counted backwards from yesterday over committed roots — not from a counter we could quietly reset. The walk stops at the first missing day or the first day with a non-zero mismatch count.
rate limit none
{
"cleanDayStreak": 118,
"daysCommitted": 121,
"assertionsVerified": 5427,
"discrepancies": 0
}assertionsVerified and discrepancies count decided attestations across all time, so they include days whose roots have not been committed yet. That is why assertionsVerified can exceed the sum of leafCount over /v1/roots.
Speed layer
Telemetry is free. Execution is metered and tiered.
/v1/feed/stats
free · no keyEverything we know about our own feed connection, including the measured lead over the operator’s public RPC. enabled is false when the feed hub is not running in this process, in which case no other field is present.
rate limit none
{
"enabled": true,
"connected": true,
"url": "wss://feed.mainnet.chain.robinhood.com",
"lastSequenceNumber": 19001111,
"lastMessageAt": 1784917231442,
"messagesSeen": 8412663,
"transactionsSeen": 11930847,
"reconnects": 3,
"subscribers": 27,
"blockIntervalMs": {
"count": 4096, "min": 61, "p50": 76, "p90": 104,
"p99": 213, "max": 1884, "mean": 84.3
},
"leadMs": {
"count": 1024, "min": 214, "p50": 731, "p90": 1180,
"p99": 2093, "max": 4417, "mean": 792.6
},
"lastError": null,
"note": "leadMs is measured continuously against the public RPC, not asserted."
}leadMs is sampled, not exhaustive: roughly one transaction in four hundred is picked and polled against the public RPC until it appears, because a measurement that loads the endpoint it is measuring is not a measurement. Both windows are ring buffers — 4096 samples for intervals, 1024 for lead — so the percentiles describe recent behaviour, not all of history. lastMessageAt and observedAt are milliseconds since the epoch, stamped locally before parsing.
/v1/radar
free · metered by tierRecorded launch signals, newest first, plus a count by kind. This HTTP route has no tier gate — anonymous callers read it at the public rate. The live socket does: see /v1/radar/ws.
rate limit bucket radar, tier rpcRateLimit per second
| Param | Type | Default | Notes |
|---|---|---|---|
| limit | integer | 50 | Between 1 and 200. |
{
"items": [
{
"id": 90412,
"kind": "pool_created_v3",
"source": "feed",
"confirmed": false,
"sequenceNumber": "19001143",
"txHash": "0xe07b41ca92d6580f3b1caf7d40e29586c1b3fa740d92e6c85137b0af62d1c94e",
"fromAddress": "0x2BAD8182C09F50c8318d769245beA52C32Be46CD",
"toAddress": "0x1f7d7550B1b028f7571E69A784071F0205FD2EfA",
"poolAddress": null,
"tokens": [
"0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73",
"0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168"
],
"fee": 3000,
"label": "UniswapV3Factory.createPool WETH / USDG fee 3000",
"observedAt": "1784917244108",
"leadMs": 664,
"createdAt": "2026-07-24T18:20:44.311Z"
}
],
"counts": [
{ "kind": "pool_created_v2", "n": 1841 },
{ "kind": "pool_created_v3", "n": 903 },
{ "kind": "pool_created_v4", "n": 77 },
{ "kind": "liquidity_added", "n": 2260 },
{ "kind": "router_swap", "n": 18854 },
{ "kind": "contract_deployed", "n": 512 }
],
"note": "source=feed signals are pre-execution intent; source=chain signals are confirmed."
}A source: "feed" row is decoded calldata that has not executed yet — it can revert, and poolAddress is null until the matching log is seen. A source: "chain" row is confirmed. Rows are stored, so sequenceNumber and observedAt come back as decimal strings; the socket sends the same signal with numeric fields and the names from and to.
/v1/relay/stats
free · no keyBundle relay counters for this process, including the measured burst contiguity rate. When the relay is not running the entire body is {"enabled": false} — the shape changes, it does not degrade into zeros.
rate limit none
{
"watching": true,
"accepted": 1284,
"included": 1207,
"awaitingInclusion": 2,
"rejectedPreflight": 41,
"rejectedByPrecondition": 34,
"inclusionLatencyMs": {
"count": 1207, "min": 108, "p50": 214, "p90": 338,
"p99": 741, "max": 2904, "mean": 241.7
},
"burst": { "total": 96, "contiguous": 91, "contiguityRate": 0.9479166666666666 }
}rejectedByPrecondition is the number the product is sold on: submissions the sequencer dropped before inclusion, at zero gas, because the state they assumed had moved. contiguityRate is null until at least one burst has been submitted; it is measured and never promised.
/v1/simulate
pro and aboveRun up to 32 calls through eth_simulateV1 against the freshest state the node holds. Returns 402 below Pro, 503 relay_disabled when the relay is not running in this process, and 400 with zod issues on a malformed body.
rate limit bucket simulate, max(1, simulateRpm / 60) per second
{
"from": "0x9d61c4B7e02aF385Ac1D07b3924cE68fbA0517d2",
"calls": [
{
"to": "0x8876789976dEcBfCbBbe364623C63652db8C0904",
"data": "0x24856bc30000000000000000000000000000000000000000000000000000000000000040",
"value": "0",
"gas": "400000"
}
]
}| Field | Type | Required | Notes |
|---|---|---|---|
| from | address | yes | Must match ^0x[0-9a-fA-F]{40}$. |
| calls | array | yes | One to 32 entries, executed in order against the same state. |
| calls[].to | address | null | no | Null or omitted for a deployment. |
| calls[].data | hex | yes | May be 0x. |
| calls[].value | decimal string | no | Wei. A string, because it is a uint256. |
| calls[].gas | decimal string | no | Per-call gas cap. |
{
"ok": true,
"calls": [
{
"ok": true,
"gasUsed": "184213",
"returnData": "0x0000000000000000000000000000000000000000000000006124fee993bc0000",
"revertReason": null
}
],
"blockNumber": "19001114",
"freshnessMs": 680,
"totalGasUsed": "184213",
"error": null
}freshnessMs is how long the simulation itself took against our head, not a guarantee about the future. A simulation is a forecast: state moves between the simulation and the execution, and there is no indemnity attached to the result. Standard Error(string) reverts are decoded into revertReason; anything else comes back as the four-byte selector.
/v1/bundles
pro and aboveSubmit one atomic transaction, or a burst of sequential ones. Atomicity is the EVM’s, not ours: the atomic path is a single signed transaction against your own executor contract. The relay adds a preflight simulation and sequencer-enforced preconditions, both of which fail for free.
rate limit bucket bundle, max(1, bundleRpm / 60) per second
| Field | Type | Default | Notes |
|---|---|---|---|
| mode | 'atomic' | 'burst' | 'atomic' | Burst is explicitly non-atomic. |
| rawTransaction | hex | — | Required in atomic mode. Missing gives a 400 with a message. |
| rawTransactions | hex[] | — | Required in burst mode. Two to sixteen entries, sequential nonces from one sender. |
| simulateFirst | boolean | true | Atomic mode. Preflight through eth_simulateV1 before submitting. |
| conditional | object | unset | Atomic mode. Passed to eth_sendRawTransactionConditional: knownAccounts, blockNumberMin, blockNumberMax, timestampMin, timestampMax. |
{
"mode": "atomic",
"rawTransaction": "0x02f8b20182...",
"simulateFirst": true,
"conditional": {
"knownAccounts": {
"0x8366a39CC670B4001A1121B8F6A443A643e40951":
"0x1c0d47b25a3fe8901b6c4d2f7e05398ab41ce6207fd9385b0a1e6cd4739fb82e"
},
"blockNumberMin": "19001114",
"blockNumberMax": "19001134"
}
}{
"bundleId": "bnd_9f14a0c73be5d820471c6ea9",
"mode": "atomic",
"status": "included",
"transactionHashes": [
"0x4d17b9e0af35c26810df7b4a92e5013cf760b28d419ae735062cf18ba9d3417e"
],
"sequenceNumber": 19001119,
"blockNumber": "19001119",
"inclusionLatencyMs": 214,
"contiguous": null,
"gasUsed": "191044",
"error": null,
"simulation": {
"ok": true,
"calls": [
{ "ok": true, "gasUsed": "191044", "returnData": "0x", "revertReason": null }
],
"blockNumber": "19001114",
"freshnessMs": 680,
"totalGasUsed": "191044",
"error": null
},
"createdAt": 1784917240113,
"updatedAt": 1784917240327
}status is one of queued, armed, submitted, included, rejected or reverted. contiguous is null in atomic mode, where the question is meaningless. Timestamps here are milliseconds.
{
"bundleId": "bnd_31c78ad0f2e5904b6d17ac83",
"mode": "burst",
"status": "included",
"contiguous": true,
"warning": "Burst mode is NOT atomic. Sequential nonces from one sender usually land contiguously under FCFS, but a foreign transaction or a block boundary can split them. See /v1/relay/stats for the measured contiguity rate."
}The warning is part of the response body, not a footnote in these docs, because the caller that most needs to read it is a program.
/v1/bundles/:id
free · no keyThe stored row for one bundle. No tier gate and no bucket: a receipt you already hold the id for is not a metered resource. 404 for an unknown id, 503 relay_disabled when the relay is not running here. The id must be 4 to 64 characters.
rate limit none
{
"id": "bnd_9f14a0c73be5d820471c6ea9",
"mode": "atomic",
"status": "included",
"apiKeyId": 12,
"transactionHashes": [
"0x4d17b9e0af35c26810df7b4a92e5013cf760b28d419ae735062cf18ba9d3417e"
],
"sequenceNumber": "19001119",
"blockNumber": "19001119",
"inclusionLatencyMs": 214,
"contiguous": null,
"gasUsed": "191044",
"simulation": { "ok": true, "blockNumber": "19001114", "freshnessMs": 680 },
"conditional": { "blockNumberMin": "19001114", "blockNumberMax": "19001134" },
"error": null,
"createdAt": "2026-07-24T18:20:40.113Z",
"updatedAt": "2026-07-24T18:20:40.327Z"
}This is the database row, not the in-memory receipt: timestamps are ISO strings and chain quantities are decimal strings. The POST response is the authoritative shape for a live submission.
Streams
Both sockets are send-only. The server never reads an inbound frame: there is no subscribe message, no filter negotiation and no ping we originate. You connect, you receive, you reconnect on close. Authenticate with ?key=, since a browser cannot set headers on a WebSocket.
/v1/feed/ws
free · delayed 2000 ms without a keyA relay of the Robinhood Chain sequencer feed, decoded into blocks and transaction hashes. One hello frame, then one block frame per feed message for as long as you stay connected.
rate limit none
{
"type": "hello",
"tier": "public",
"feedDelayMs": 2000,
"note": "Public tier is delayed by 2000 ms on purpose. Real-time access funds the watchtower."
}On a paid tier the same frame reads "feedDelayMs": 0 and "note": "Real-time.". Read tier here to confirm which identity your key resolved to.
{
"type": "block",
"sequenceNumber": 19001111,
"timestamp": 1784917231,
"parentBlockNumber": 23617884,
"observedAt": 1784917231442,
"transactions": [
{ "hash": "0x9c15d0be7a34f2810cd6b47e93af5021b7de4c8093f16ea5720bd3c48f1e6a07", "index": 0 },
{ "hash": "0x2e71fac03b9d4650812fe7c19a5db40736c1e8420df95ab607c3d18e5b0a4f92", "index": 1 }
]
}| Field | Type | Notes |
|---|---|---|
| sequenceNumber | number | The feed sequence number, which on this chain equals the L2 block number — verified empirically, not assumed. |
| timestamp | number | Sequencer-assigned, in whole seconds. Blocks arrive far faster than this advances. |
| parentBlockNumber | number | The Ethereum block the sequencer stamped onto the message. |
| observedAt | number | Milliseconds since the epoch, stamped on our machine the instant the frame came off the socket and before any parsing, so a comparison against your own clock measures the network rather than our decoder. |
| transactions | array | Hash and position within the message. Raw payloads are not relayed. |
The public tier is delayed by 2000 ms deliberately. It is not congestion, a queue or a best-effort ceiling — it is a timer, applied per subscriber inside the feed hub, so exactly one place in the codebase decides who receives data when. Paid tiers are forwarded as fast as the process can write them. If you disconnect, pending delayed deliveries are cancelled rather than fired into a closed socket.
The upstream feed at wss://feed.mainnet.chain.robinhood.com is public and unauthenticated. You can connect to it yourself and skip us entirely — we say so on this page because it is true, and because what we sell above that is proximity and a decoder, not access.
{ "type": "error", "message": "feed disabled on this node" }Sent, then the socket is closed immediately.
/v1/radar/ws
pro and aboveLaunch signals the instant they are decoded from the feed, ahead of the log that confirms them. Requires a tier with radarAccess; anything less receives an error frame and a close, not a 402, because the handshake has already succeeded by then.
rate limit none
{ "type": "hello", "tier": "pro" }{
"type": "signal",
"kind": "pool_created_v3",
"source": "feed",
"confirmed": false,
"sequenceNumber": 19001143,
"txHash": "0xe07b41ca92d6580f3b1caf7d40e29586c1b3fa740d92e6c85137b0af62d1c94e",
"from": "0x2BAD8182C09F50c8318d769245beA52C32Be46CD",
"to": "0x1f7d7550B1b028f7571E69A784071F0205FD2EfA",
"tokens": [
"0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73",
"0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168"
],
"poolAddress": null,
"fee": 3000,
"observedAt": 1784917244108,
"label": "UniswapV3Factory.createPool WETH / USDG fee 3000"
}kind is one of pool_created_v2, pool_created_v3, pool_created_v4, liquidity_added, router_swap or contract_deployed. A source: "feed" signal is intent read off calldata before execution — it can revert, and it is not advice. The confirming source: "chain" signal for the same transaction arrives separately, with poolAddress filled in.
{ "type": "error", "message": "launch radar requires the Pro tier or above" }
{ "type": "error", "message": "radar disabled on this node" }Error shapes
Every error is JSON with a machine-readable error string. Branch on that, never on message.
| Status | error | Raised by | Notes |
|---|---|---|---|
| 400 | bad_request | zod, or an explicit check | Schema failures carry the raw zod issues array. The two mode checks on /v1/bundles carry a message instead. |
| 402 | tier_required | /v1/simulate, /v1/bundles | Carries required, actual and an upgrade URL. Payment Required is the honest code here: the resource exists and your identity is valid, it is just not paid for. |
| 404 | not_found | /v1/attestations/:hash, /v1/bundles/:id | Well-formed identifier, no such row. |
| 429 | rate_limited | radar, simulate, bundle buckets | Names the tier and the bucket that rejected you. No Retry-After header: the bucket refills continuously, so retry after 1 / rate seconds. |
| 503 | status_unavailable | /v1/status | No snapshot yet, or the status service is not running in this process. |
| 503 | relay_disabled | /v1/simulate, /v1/bundles, /v1/bundles/:id | The relay is not running in this process. A deployment can run any subset of the services. |
| 500 | internal | anywhere | Unhandled. Carries the error message and is logged with the URL on our side. |
{
"error": "bad_request",
"issues": [
{
"code": "invalid_enum_value",
"options": ["match", "mismatch", "pending", "lagging", "unavailable"],
"path": ["verdict"],
"message": "Invalid enum value. Expected 'match' | 'mismatch' | 'pending' | 'lagging' | 'unavailable', received 'good'"
}
]
}{ "error": "bad_request", "message": "burst mode needs rawTransactions" }{
"error": "tier_required",
"message": "this endpoint requires the Pro tier or above; the presented key is Public",
"required": "pro",
"actual": "public",
"upgrade": "https://verderer.watch/speed"
}actual is the tier your credential resolved to. Seeing public here when you sent a key means the key was unknown, revoked or expired — it was not rejected, it was downgraded.
{
"error": "rate_limited",
"message": "Public tier allows 5 requests per second on radar",
"tier": "public"
}{ "error": "not_found" }
{ "error": "status_unavailable" }
{ "error": "relay_disabled" }Verify an attestation without trusting this API
An attestation is only worth what it is worth without us. Two independent checks, and neither needs this server to be honest — or, once you hold the payload, to be running at all.
The domain is the AttestationRegistry deployment on Robinhood Chain. Take the address from deployed.attestationRegistry in /v1/meta rather than hard-coding it.
| Domain field | Value |
|---|---|
| name | "Verderer Attestation" |
| version | "1" |
| chainId | 4663 |
| verifyingContract | the AttestationRegistry address from /v1/meta |
The struct has nine fields and the order is load-bearing — the Solidity typehash in AttestationRegistry.sol encodes them in exactly this sequence, and the test suite asserts the TypeScript digest equals the Solidity one. A reordering silently produces a different digest and a signature that recovers to a stranger.
| # | Field | Type | Notes |
|---|---|---|---|
| 1 | assertionHash | bytes32 | The rollup assertion being judged. |
| 2 | parentAssertionHash | bytes32 | Its parent, which pins the chain of claims. |
| 3 | claimedBlockHash | bytes32 | From the assertion. |
| 4 | claimedSendRoot | bytes32 | From the assertion. This is the field a bridge theft would have to move. |
| 5 | localSendRoot | bytes32 | What our own replay produced. Zero when we could not resolve one, which is only possible on a mismatch. |
| 6 | l2BlockNumber | uint64 | Our local block number for the claimed hash. |
| 7 | parentBlockNumber | uint64 | The Ethereum block that carried the assertion. |
| 8 | verdict | uint8 | 0 = match, 1 = mismatch. Nothing else is ever signed. |
| 9 | checkedAt | uint64 | Unix seconds at which the check completed. |
import { recoverTypedDataAddress, keccak256, encodeAbiParameters } from 'viem';
const BASE = 'https://api.example'; // the base URL above
const REGISTRY = meta.deployed.attestationRegistry;
const a = await fetch(BASE + '/v1/attestations/' + assertionHash).then((r) => r.json());
const domain = {
name: 'Verderer Attestation',
version: '1',
chainId: 4663,
verifyingContract: REGISTRY,
};
const types = {
Attestation: [
{ name: 'assertionHash', type: 'bytes32' },
{ name: 'parentAssertionHash', type: 'bytes32' },
{ name: 'claimedBlockHash', type: 'bytes32' },
{ name: 'claimedSendRoot', type: 'bytes32' },
{ name: 'localSendRoot', type: 'bytes32' },
{ name: 'l2BlockNumber', type: 'uint64' },
{ name: 'parentBlockNumber', type: 'uint64' },
{ name: 'verdict', type: 'uint8' },
{ name: 'checkedAt', type: 'uint64' },
],
};
const ZERO = '0x' + '00'.repeat(32);
const message = {
assertionHash: a.assertionHash,
parentAssertionHash: a.parentAssertionHash,
claimedBlockHash: a.claimed.blockHash,
claimedSendRoot: a.claimed.sendRoot,
localSendRoot: a.local.sendRoot ?? ZERO,
l2BlockNumber: BigInt(a.local.blockNumber ?? 0),
parentBlockNumber: BigInt(a.parent.blockNumber),
verdict: a.verdict === 'mismatch' ? 1 : 0,
checkedAt: BigInt(a.checkedAt),
};
const signer = await recoverTypedDataAddress({
domain, types, primaryType: 'Attestation', message, signature: a.signature,
});
// Compare against a.signer, and against isActiveAttester(signer) on the registry.
// If they disagree, the API lied and the signature says so.Recovering a signer proves we said it. Inclusion proves we said it at the time, in a root that was committed publicly before you asked — which is the part that makes quiet retraction impossible. The leaf is the OpenZeppelin double hash of the EIP-712 digest, pairs are sorted before hashing, so the proof carries no direction bits and MerkleProof.verify works unmodified.
const digest = hashTypedData({ domain, types, primaryType: 'Attestation', message });
const leaf = keccak256(keccak256(digest)); // OpenZeppelin's double hash
// On-chain, against the registry on Robinhood Chain (4663):
// verifyInclusion(uint32 day, bytes32 leaf, bytes32[] proof) -> bool
const included = await registry.read.verifyInclusion([
a.proof.day,
leaf,
a.proof.siblings,
]);
// Or in one call, which also recovers and checks the attester:
// verifyAttestationInclusion(day, attestation, signature, proof)
// -> (bool authentic, bool included, address signer)verifyInclusion reads the root out of the registry’s own storage, so a true result means the day’s committed root contains that leaf, whatever this API says today. The same root is mirrored to CheckpointAnchor on Ethereum, so the record outlives both this service and Robinhood Chain. A day with no committed root returns false rather than reverting — absence is not disagreement, and the two must not be confused.
Run your own watchtower
Please do. An optimistic chain is exactly as safe as the number of parties independently recomputing its assertions, and that number is currently small enough to write down. A second node that disagrees with ours is worth more to this chain than a faster one, and it costs Robinhood nothing to have you watching — the feed is public and unauthenticated, and no allowlist is involved in reading.
A Nitro node in Watchtower mode stakes nothing and posts nothing. It validates every assertion locally against its own replay and logs loudly when one disagrees. That is the whole job, and on this chain it is also the ceiling: the challenge manager gates on validatorWhitelistDisabled(), which is false, so no node you run can open a challenge either. Detection and publication are what is available to everyone, us included.
docker run --rm -it \ -v /srv/hoodchain:/home/user/.arbitrum \ -p 8547:8547 \ offchainlabs/nitro-node:v3.11.2-3599aca \ --parent-chain.connection.url=https://your-ethereum-node \ --chain.id=4663 \ --chain.info-json-url=https://cdn.robinhood.com/assets/generated_assets/hoodchain_docsite/chain-node-configs/robinhood-chain-info.json \ --node.feed.input.url=wss://feed.mainnet.chain.robinhood.com \ --node.staker.enable=true \ --node.staker.strategy=Watchtower \ --node.block-validator.enable=true \ --http.addr=0.0.0.0 \ --http.api=eth,net,web3,arb
| Flag | Why |
|---|---|
| --node.staker.enable=true | Turns on the staker loop. Watchtower strategy stakes nothing, so no funded wallet is required. |
| --node.staker.strategy=Watchtower | Validate and report, never post. The only strategy available to a non-allowlisted address here. |
| --node.block-validator.enable=true | Runs the block validator. Without it the staker has nothing independent to compare an assertion against, and the node is just a reader. |
| --node.feed.input.url | The public sequencer feed. Keeps the node at the head with ~100 ms blocks instead of waiting for batches to appear on Ethereum. |
| --chain.info-json-url | Robinhood's own published chain config. Preferred over a hand-written genesis, because a hand-written one is a silent way to validate the wrong chain. |
| --parent-chain.connection.url | An Ethereum endpoint that serves eth_getLogs over wide ranges. Most free endpoints cap the range and are useless for assertion scanning. |
16 cores, 64 GB RAM, NVMe storage for the core node. That is a floor, not a recommendation: the block validator replays execution, and spinning disks or network block storage will put you permanently behind a chain producing a block every ~100 ms. Budget separately for the Ethereum endpoint, which is the part people forget.
Once your node is synced you have everything you need to audit us. Pull /v1/attestations, take claimed.sendRoot and claimed.blockHash from each row, and check them against your own node’s block. If your answer differs from ours, publish it — loudly, and against us if that is where the evidence points. We would rather be corrected in public than be the only voice in the room.
The full source for the verifier, the attestation schema and the daily roots is in the repository this API is built from, including the exact comparison this node performs. Nothing in the checking half of the product is proprietary, because a watchtower whose method is secret is asking for the same trust it exists to replace.
A route documented here that does not behave as described is a bug, and worth more to us reported than tolerated. What we cannot do lists the boundaries this API cannot cross no matter what you pay.