Last 24hassertions verified316discrepancies0feed lead p50+836mschain head21,965,794rollup0x23A19d…2D94
API reference

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.

How to read the payloads on this page

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.

Service state on the node this page was rendered against
watchtoweroperationalfeedoperationalradaroperationalrelayoperationaluptime 4,285 s

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.

OrderWhereFormNotes
1AuthorizationBearer <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.
2X-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.
Storage

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 unusable key downgrades, it does not reject

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.

Tierfeed delayradar req/ssimulate req/sbundles req/sRadar socketArchive reads
Public2,000 ms5no accessno accessnono
Proreal time100101yesno
Turboreal time1,00010010yesyes
Colocatedreal time10,0001,000100yesyes

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.

GET

/health

free · no key

Liveness 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

Response
{
  "ok": true,
  "uptimeSeconds": 419377,
  "services": {
    "watchtower": "operational",
    "feed": "operational",
    "radar": "operational",
    "relay": "operational"
  }
}
GET

/v1/meta

free · no key

Static-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

Response
{
  "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.

GET

/v1/status

free · no key

The 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

Response
{
  "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.

GET

/v1/watchtower

free · no key

What 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

Response
{
  "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.

GET

/v1/attestations

free · no key

The 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

ParamTypeDefaultNotes
limitinteger50Between 1 and 500. Coerced from the string, so ?limit=100 is fine.
offsetinteger0Zero or greater.
verdictenumunsetOne of match, mismatch, pending, lagging, unavailable. Anything else is a 400 with the zod issue attached.
Response
{
  "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.

GET

/v1/attestations/:hash

free · no key

One 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

Additional field, present only once the day’s root exists
{
  // ... 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.

GET

/v1/assertions

free · no key

Raw 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

ParamTypeDefaultNotes
limitinteger501 to 500.
offsetinteger0Zero or greater.
verdictenumunsetAccepted by the shared query schema and then ignored on this route: assertions carry no verdict. Filter on /v1/attestations instead.
Response
{
  "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.

GET

/v1/roots

free · no key

The 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

Response
{
  "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.

GET

/v1/streak

free · no key

Consecutive 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

Response
{
  "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.

GET

/v1/feed/stats

free · no key

Everything 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

Response
{
  "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.

GET

/v1/radar

free · metered by tier

Recorded 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

ParamTypeDefaultNotes
limitinteger50Between 1 and 200.
Response
{
  "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.

GET

/v1/relay/stats

free · no key

Bundle 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

Response
{
  "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.

POST

/v1/simulate

pro and above

Run 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

Request body
{
  "from": "0x9d61c4B7e02aF385Ac1D07b3924cE68fbA0517d2",
  "calls": [
    {
      "to": "0x8876789976dEcBfCbBbe364623C63652db8C0904",
      "data": "0x24856bc30000000000000000000000000000000000000000000000000000000000000040",
      "value": "0",
      "gas": "400000"
    }
  ]
}
FieldTypeRequiredNotes
fromaddressyesMust match ^0x[0-9a-fA-F]{40}$.
callsarrayyesOne to 32 entries, executed in order against the same state.
calls[].toaddress | nullnoNull or omitted for a deployment.
calls[].datahexyesMay be 0x.
calls[].valuedecimal stringnoWei. A string, because it is a uint256.
calls[].gasdecimal stringnoPer-call gas cap.
Response
{
  "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.

POST

/v1/bundles

pro and above

Submit 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

FieldTypeDefaultNotes
mode'atomic' | 'burst''atomic'Burst is explicitly non-atomic.
rawTransactionhexRequired in atomic mode. Missing gives a 400 with a message.
rawTransactionshex[]Required in burst mode. Two to sixteen entries, sequential nonces from one sender.
simulateFirstbooleantrueAtomic mode. Preflight through eth_simulateV1 before submitting.
conditionalobjectunsetAtomic mode. Passed to eth_sendRawTransactionConditional: knownAccounts, blockNumberMin, blockNumberMax, timestampMin, timestampMax.
Request body — atomic, guarded
{
  "mode": "atomic",
  "rawTransaction": "0x02f8b20182...",
  "simulateFirst": true,
  "conditional": {
    "knownAccounts": {
      "0x8366a39CC670B4001A1121B8F6A443A643e40951":
        "0x1c0d47b25a3fe8901b6c4d2f7e05398ab41ce6207fd9385b0a1e6cd4739fb82e"
    },
    "blockNumberMin": "19001114",
    "blockNumberMax": "19001134"
  }
}
Response
{
  "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.

Burst mode carries its own warning in the payload
{
  "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.

GET

/v1/bundles/:id

free · no key

The 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

Response
{
  "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.

WS

/v1/feed/ws

free · delayed 2000 ms without a key

A 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

hello — first frame, always
{
  "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.

block — one per feed message
{
  "type": "block",
  "sequenceNumber": 19001111,
  "timestamp": 1784917231,
  "parentBlockNumber": 23617884,
  "observedAt": 1784917231442,
  "transactions": [
    { "hash": "0x9c15d0be7a34f2810cd6b47e93af5021b7de4c8093f16ea5720bd3c48f1e6a07", "index": 0 },
    { "hash": "0x2e71fac03b9d4650812fe7c19a5db40736c1e8420df95ab607c3d18e5b0a4f92", "index": 1 }
  ]
}
FieldTypeNotes
sequenceNumbernumberThe feed sequence number, which on this chain equals the L2 block number — verified empirically, not assumed.
timestampnumberSequencer-assigned, in whole seconds. Blocks arrive far faster than this advances.
parentBlockNumbernumberThe Ethereum block the sequencer stamped onto the message.
observedAtnumberMilliseconds 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.
transactionsarrayHash and position within the message. Raw payloads are not relayed.
Delay semantics

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.

error — feed not running on this node
{ "type": "error", "message": "feed disabled on this node" }

Sent, then the socket is closed immediately.

WS

/v1/radar/ws

pro and above

Launch 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

hello
{ "type": "hello", "tier": "pro" }
signal
{
  "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.

error — insufficient tier, or radar not running here
{ "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.

StatuserrorRaised byNotes
400bad_requestzod, or an explicit checkSchema failures carry the raw zod issues array. The two mode checks on /v1/bundles carry a message instead.
402tier_required/v1/simulate, /v1/bundlesCarries 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.
404not_found/v1/attestations/:hash, /v1/bundles/:idWell-formed identifier, no such row.
429rate_limitedradar, simulate, bundle bucketsNames the tier and the bucket that rejected you. No Retry-After header: the bucket refills continuously, so retry after 1 / rate seconds.
503status_unavailable/v1/statusNo snapshot yet, or the status service is not running in this process.
503relay_disabled/v1/simulate, /v1/bundles, /v1/bundles/:idThe relay is not running in this process. A deployment can run any subset of the services.
500internalanywhereUnhandled. Carries the error message and is logged with the URL on our side.
400 — schema failure
{
  "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'"
    }
  ]
}
400 — explicit check on /v1/bundles
{ "error": "bad_request", "message": "burst mode needs rawTransactions" }
402 — tier required
{
  "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.

429 — rate limited
{
  "error": "rate_limited",
  "message": "Public tier allows 5 requests per second on radar",
  "tier": "public"
}
404 and 503
{ "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.

1 · Recover the EIP-712 signer

The domain is the AttestationRegistry deployment on Robinhood Chain. Take the address from deployed.attestationRegistry in /v1/meta rather than hard-coding it.

Domain fieldValue
name"Verderer Attestation"
version"1"
chainId4663
verifyingContractthe 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.

#FieldTypeNotes
1assertionHashbytes32The rollup assertion being judged.
2parentAssertionHashbytes32Its parent, which pins the chain of claims.
3claimedBlockHashbytes32From the assertion.
4claimedSendRootbytes32From the assertion. This is the field a bridge theft would have to move.
5localSendRootbytes32What our own replay produced. Zero when we could not resolve one, which is only possible on a mismatch.
6l2BlockNumberuint64Our local block number for the claimed hash.
7parentBlockNumberuint64The Ethereum block that carried the assertion.
8verdictuint80 = match, 1 = mismatch. Nothing else is ever signed.
9checkedAtuint64Unix 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.
2 · Check Merkle inclusion against the day’s root

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.

What a passing check does and does not prove
It proves that a registered attester signed that exact verdict, and that the verdict was inside the tree committed for that day. It does not prove the verdict is correct. The only thing that proves that is running the replay yourself — which is the next section, and which is the point.

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.

The node
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
FlagWhy
--node.staker.enable=trueTurns on the staker loop. Watchtower strategy stakes nothing, so no funded wallet is required.
--node.staker.strategy=WatchtowerValidate and report, never post. The only strategy available to a non-allowlisted address here.
--node.block-validator.enable=trueRuns 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.urlThe 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-urlRobinhood'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.urlAn Ethereum endpoint that serves eth_getLogs over wide ranges. Most free endpoints cap the range and are useless for assertion scanning.
Hardware floor

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.

Then compare

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.