Arch Network
Docs
Sign in

WebSockets

Subscribe to realtime blocks, transactions, and account updates over a single WebSocket connection.

Endpoints & authentication

The realtime API exposes one WebSocket endpoint per network:

  • wss://explorer.arch.network/ws/mainnet – mainnet realtime events.
  • wss://explorer.arch.network/ws/testnet – testnet realtime events.
  • wss://explorer.arch.network/ws – legacy endpoint; always resolves to testnet. Prefer the network-scoped URLs.

Each connection only receives events for its network, and every event carries a network field ("mainnet" or "testnet") so consumers can verify the source chain.

WebSocket connections require an API key — the same key used for REST — passed as the apikey query parameter (api_key is also accepted). The key is checked before the upgrade, so failures arrive as an ordinary HTTP response with a JSON body rather than as a WebSocket message:

# 401 – no apikey / api_key query parameter
{
  "error": "missing_api_key",
  "message": "API key is required as apikey query parameter for WebSocket connections."
}

# 401 – key not found
{
  "error": "invalid_api_key",
  "message": "The provided API key is invalid for WebSocket connection."
}

# 403 – key revoked, or account not active
{
  "error": "key_revoked_or_inactive",
  "message": "This API key or account is not active."
}

# 404 – /ws/:network with an unknown network
{
  "error": "invalid_network",
  "message": "Unknown network; expected 'testnet' or 'mainnet'.",
  "network": "devnet"
}

# 500 – auth lookup failed
{
  "error": "auth_backend_error",
  "message": "Authentication backend error."
}

Connection URL

wss://explorer.arch.network/ws/mainnet?apikey=YOUR_API_KEY

Client messages

Send JSON text frames with a method field. Two methods are recognized, subscribe and ping, and each returns exactly one reply. Every reply is a JSON object with a status field, which is how you tell control replies apart from events (events have topic instead).

subscribe

{
  "method": "subscribe",
  "topic": "block"
}

Reply:

{
  "status": "Subscribed",
  "client_id": "client_id_2f8c1d54-9a1e-4f7d-8f4a-6b0f1d2e3c44",
  "network": "mainnet",
  "topics": ["block"],
  "filter": {},
  "message": "Successfully subscribed to real-time events"
}

Subscribing narrows the stream: once a connection subscribes to a topic it receives that topic only. Calls accumulate, so subscribe once per topic you want, and read topics in the reply to confirm what is now in effect. client_id is a per-connection identifier assigned by the server and appears only in this reply.

A connection that never subscribes receives every topic for its network, and a subscribe naming no topic at all widens it back to every topic. Note that block and block_activity are separate subscriptions — subscribing to block alone excludes the activity counters.

Filtering by field

Add a filter to receive only events whose data fields match. The request uses the same params envelope as the Arch SDK, and request_id is echoed on the reply:

{
  "method": "subscribe",
  "params": {
    "topic": "account_update",
    "filter": { "account": "ea5861a47d8a106d547f29d184feed28a07e49038a94148bd43247bf50fc79da" },
    "request_id": "my_vault"
  }
}
  • Every field in a filter must match. An event missing the field does not match.
  • A list value is any-of: { "account": ["<a>", "<b>"] } (up to 256 values).
  • A single value also matches inside list fields, so { "program_ids": "<program>" } on transaction selects transactions that invoke that program.
  • Matching is exact; ids are lowercase hex. Repeated filtered subscribes to one topic combine as any-of (up to 64); an unfiltered subscribe to that topic receives all of it.
  • A filter only matches events when that account or program actually changes. An idle account produces no events, so check its recent history over REST before assuming the stream is broken.

An unrecognized topic is rejected and leaves the existing subscriptions untouched, so a typo cannot silently narrow the stream to nothing:

{
  "status": "error",
  "error": "Unknown topic: blocks",
  "topics": ["block"]
}

There is no unsubscribe method. Reconnect to drop topics, and close the socket to stop the stream.

ping

{
  "method": "ping"
}

Reply — timestamp is the server's Unix time in seconds, unlike the RFC 3339 timestamp on events:

{
  "status": "pong",
  "timestamp": 1735689600
}

Unknown methods

Any other method value is answered with an error naming it. The connection stays open.

{
  "status": "error",
  "error": "Unknown method: unknown_method"
}

Other frames

  • Text that is not valid JSON, or JSON without a method field, is dropped with no reply at all — do not wait on a response to malformed input.
  • A protocol-level Ping frame is answered with a Pong carrying the same payload.
  • A Close frame ends the connection. Binary frames are ignored silently.
  • The server never initiates pings, so an idle connection produces no traffic. Send { "method": "ping" } periodically if a proxy between you and the API closes idle connections.

Server events

Every event has the same envelope. timestamp is RFC 3339 UTC and records when the API emitted the event, not when the block was produced — chain time, where present, lives inside data.

{
  "topic": "block",
  "data": { },
  "timestamp": "2025-01-01T00:00:00.123456Z",
  "network": "mainnet"
}

Most topics are relayed from the validator feed verbatim, so data may carry fields beyond those documented here; the fields below are the ones the API itself reads and are the only ones safe to depend on. block and block_activity are additionally emitted by the API's own aggregation, which means more than one message per block. The emitted topics are:

  • block – new blocks, relayed and then enriched.
  • block_activity – debounced per-height transaction and program counters.
  • transaction – transactions as they are processed.
  • account_update – account state changes.
  • rolledback_transactions – transactions rolled back due to a reorg.
  • reapplied_transactions – transactions re-applied after a reorg.
  • dkg – distributed key generation / validator coordination events.

block

The relayed event arrives first, carrying only what the validator reported — hash and a chain timestamp in seconds:

{
  "topic": "block",
  "data": {
    "hash": "8f2b...c41d",
    "timestamp": 1735689600
  },
  "timestamp": "2025-01-01T00:00:00.101Z",
  "network": "mainnet"
}

Once the block is in the index, a second block event follows with the enriched payload. Note that data.timestamp here is epoch milliseconds, height and block_height are the same value, and program_counts maps program id to transaction count for the 64 busiest programs in the block:

{
  "topic": "block",
  "data": {
    "hash": "8f2b...c41d",
    "height": 543210,
    "block_height": 543210,
    "timestamp": 1735689600000,
    "transaction_count": 42,
    "program_counts": {
      "00000000000000000000000000000001": 30,
      "a1b2...f9e8": 12
    }
  },
  "timestamp": "2025-01-01T00:00:00.412Z",
  "network": "mainnet"
}

If the block has not been persisted yet, the follow-up carries only hash and timestamp; the enriched form does not arrive retroactively. Key off data.height being present to distinguish the variants.

block_activity

A running count for the block currently being filled, emitted at most once every 250 ms per height and covering the 32 busiest programs seen so far. Counts are partial and increase as transactions arrive — use the enriched block event for final totals. The variant emitted alongside a block event also includes hash.

{
  "topic": "block_activity",
  "data": {
    "height": 543210,
    "transaction_count": 17,
    "program_counts": {
      "00000000000000000000000000000001": 11,
      "a1b2...f9e8": 6
    },
    "finalized": false,
    "timestamp": 1735689600250
  },
  "timestamp": "2025-01-01T00:00:00.250Z",
  "network": "mainnet"
}

transaction

{
  "topic": "transaction",
  "data": {
    "hash": "3c9a...77b1",
    "status": "Processed",
    "block_height": 543210,
    "program_ids": ["00000000000000000000000000000001"]
  },
  "timestamp": "2025-01-01T00:00:01Z",
  "network": "mainnet"
}

status is relayed unchanged from the validator, so it is not always a string: it is "Queued", "Processed", or the object { "Failed": "<message>" }. program_ids is the list of programs the transaction invoked, and is what drives the program_counts maps above.

account_update

{
  "topic": "account_update",
  "data": {
    "account": "b7f1...20ac",
    "transaction_hash": "3c9a...77b1"
  },
  "timestamp": "2025-01-01T00:00:02Z",
  "network": "mainnet"
}

rolledback_transactions / reapplied_transactions

{
  "topic": "rolledback_transactions",
  "data": {
    "transaction_hashes": ["3c9a...77b1", "5d0e...91f2"]
  },
  "timestamp": "2025-01-01T00:00:03Z",
  "network": "mainnet"
}
{
  "topic": "reapplied_transactions",
  "data": {
    "transaction_hashes": ["3c9a...77b1"]
  },
  "timestamp": "2025-01-01T00:00:04Z",
  "network": "mainnet"
}

dkg

{
  "topic": "dkg",
  "data": {
    "status": "completed"
  },
  "timestamp": "2025-01-01T00:00:05Z",
  "network": "mainnet"
}

Delivery semantics

  • Events are live-only. Nothing is buffered before you connect and nothing is replayed after you reconnect — backfill gaps over REST.
  • Each connection has a 100-event send buffer. A client that stops reading long enough to overflow it is dropped from the broadcast set, and the socket can stay open while silent. Treat an unexpected gap in blocks as a reason to reconnect; a subscribe sent on a connection in that state replies { "status": "error", "error": "Connection is no longer receiving events; reconnect." }.
  • A topic can repeat for the same block or transaction, as described above. Consumers should be idempotent on data.hash / data.height.

Client example

const apiKey = process.env.ARCH_API_KEY!;
const ws = new WebSocket('wss://explorer.arch.network/ws/mainnet?apikey=' + apiKey);

ws.onopen = () => {
  // Narrows the stream. Without this you receive every topic.
  ws.send(JSON.stringify({ method: 'subscribe', topic: 'block' }));
  ws.send(JSON.stringify({ method: 'subscribe', topic: 'transaction' }));

  // Keeps idle connections alive through intermediate proxies.
  setInterval(() => ws.send(JSON.stringify({ method: 'ping' })), 30_000);
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  // Control replies carry "status"; events carry "topic".
  if (msg.status) return;

  switch (msg.topic) {
    case 'block':
      // Arrives twice per block: relayed, then enriched with height/counts.
      if (msg.data.height !== undefined) console.log('[block]', msg.data.height);
      break;
    case 'transaction':
      console.log('[tx]', msg.data.hash);
      break;
    default:
      break;
  }
};

ws.onclose = () => {
  // No replay on reconnect — backfill any gap over REST.
  console.log('connection closed');
};

Indexer tip lag

Use /api/v1/:network/realtime/status to compare the Kafka indexer tip with the validator tip. This is the public lag probe for monitoring catch-up, and the way to tell a quiet chain apart from a stalled stream. status is one of synced, lagging, ahead, or degraded (the last meaning the validator tip could not be read, in which case node_tip_error is set).

GET https://explorer.arch.network/api/v1/mainnet/realtime/status
{
  "network": "mainnet",
  "ingestion_source": "kafka",
  "status": "lagging",
  "is_synced": false,
  "indexed_height": 543200,
  "indexed_block_count": 543201,
  "checkpoint_height": 543200,
  "checkpoint_event_sequence": 9812345,
  "checkpoint_updated_at": "2025-01-01T00:00:00Z",
  "checkpoint_age_seconds": 8,
  "node_height": 543210,
  "node_block_count": 543211,
  "node_minus_indexed_height": 10,
  "blocks_behind": 10,
  "latest_indexed_block_timestamp": "2024-12-31T23:59:52Z",
  "seconds_since_latest_indexed_block": 8,
  "node_tip_observed_at": "2025-01-01T00:00:00Z",
  "node_rpc_available": true,
  "node_tip_error": null,
  "checked_at": "2025-01-01T00:00:00Z"
}