Consumer Block Stream
The consumer block stream lets a gateway expose the beacon blocks it already decodes to your own downstream consumers, over WebSocket or gRPC. It is read-only, opt-in, and off by default. Each consumer authenticates with its own short-lived token, separate from the gateway's API key.
Enable it
Add the stream fields to config/app_conf.yml and restart. Only stream_enable is required; the rest have the defaults shown.
stream_enable: true # default: false
stream_only: false # default: false — skip CL; never publishes (requires stream_enable)
stream_addr: 127.0.0.1:9600 # default — WebSocket listener (own port, off /metrics)
stream_grpc_addr: 127.0.0.1:9601 # default — gRPC listener
stream_require_auth: true # verify consumer JWTs; false = loopback only
stream_max_conns: 256 # global connection cap
stream_max_conns_per_sub: 8 # per-consumer-key connection cap
stream_buffer_size: 64 # per-connection ring buffer (drop-on-overflow)Set stream_only: true if you only want the stream and do not run a consensus client. The gateway still joins the mesh but does not publish. Because it never starts the CL host, /health reports cl_peers, cl_health and subscribed_topics as skipped and returns 200 on the mesh signals alone (with telemetry_enable: true, which drives the mump2p_health check).
The gateway verifies consumer tokens against the JWKS at your remote_auth_url, so it must point at the same auth service that mints the stream tokens.
Exposure. Listeners default to loopback (
127.0.0.1:9600/127.0.0.1:9601), separate from the/metricsand/healthport. Enabling the stream does not bind every interface. A non-loopback bind is an operator choice and must sit behind a trusted TLS-terminating proxy — the gateway does not terminate TLS. Disabling auth is allowed only on a loopback bind.
Step 1 — mint a stream key (console)
Open the Optimum Partner Console and select the operator you are minting for. Check the network picker (top right) first — the key is minted for the selected chain (Hoodi or Mainnet). Open the API keys section (labelled Manage Gateways for a customer-role login), choose the Stream consumers tab, create a key, name it after the downstream consumer, and copy the value.
- The raw
osc_key is shown once and cannot be retrieved again — copy it before dismissing the dialog. - Mint one key per consumer. Revocation and the per-connection cap are both per key, so a shared key cannot be cut off individually.
- Use a Hoodi key with a Hoodi gateway, and a Mainnet key with a Mainnet gateway. The picker must match the gateway's chain, the same rule as
ogw_live_API keys.
Step 2 — exchange the key for a JWT
The consumer swaps its osc_ key for a JWT. The key is the request body, so there is no Authorization header on this call:
curl -X POST https://auth.getoptimum.io/api/v1/stream/token \
-H "content-type: application/json" \
-d '{"stream_key":"osc_live_..."}'{
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6...",
"token_type": "Bearer",
"expires_in": 3600,
"operator_id": "1"
}The access_token is the stream JWT. Decoded, its claims look like this — the gateway checks aud=stream, and caps and revocation key off sub (as_<streamKeyId>). Hoodi example:
{
"aud": "stream",
"iss": "https://auth.getoptimum.io",
"sub": "as_6772872d87e3e0e1f6a6c97630413fa9",
"operator_id": "1",
"chain_id": "560048",
"cluster_ids": ["optimum_ethereum_hoodi_v0_1"],
"iat": 1786709057,
"exp": 1786712657
}A Mainnet token uses chain_id "1" and cluster_ids: ["optimum_ethereum_mainnet_v0_1"] (or the Mainnet cluster ID Optimum assigned you at onboarding).
Tokens are short-lived (default 3600s, ceiling 21600s). A real consumer re-runs this call to refresh before expiry — the same way an OAuth client renews a token. The gateway verifies the signature locally against the published JWKS and never calls back to auth, so revoking a key stops new tokens immediately but an already-issued token keeps working until it expires. Keep the lifetime short so a cutoff takes effect quickly.
Step 3 — open a stream
There are two payload modes:
- metadata (default) — the decoded block event only: slot, proposer, roots, size, source, timing. No block bytes.
- raw — the same event plus the verbatim
ssz_snappyblock bytes as a base64rawfield.
Set the token once, then connect.
TOKEN=$(curl -s -X POST https://auth.getoptimum.io/api/v1/stream/token \
-H "content-type: application/json" \
-d '{"stream_key":"osc_live_..."}' | jq -r .access_token)WebSocket
wscat -H "Authorization: Bearer $TOKEN" \
-c "ws://GATEWAY_HOST:9600/api/v1/stream/blocks?mode=metadata"Each block arrives as one JSON frame (metadata mode):
{
"type": "block",
"slot": 3706300,
"proposer_index": 535778,
"parent_root": "saTBS/MRxOtn3FMP5vZGLhMjcgDYoJo5ISec1nGpt8M=",
"state_root": "HgD2msizU7N6A6663xIsK5LiauwtPVGTY4Gnbc9mfIg=",
"block_size_bytes": 17365,
"topic": "/eth2/c6ecb76c/beacon_block/ssz_snappy",
"source": "mump2p",
"received_at_ms": 1786689003070,
"gateway_id": "ag_...",
"fork_digest": "c6ecb76c",
"stale": false
}For raw mode, use mode=raw; the frame additionally carries the block bytes:
{ "type": "block", "slot": 3706300, "...": "...", "raw": "8gAA...base64 ssz_snappy..." }Browsers cannot set request headers, so pass the token via the Sec-WebSocket-Protocol header instead — offer two values, the marker optimum.stream.v1 and bearer.<jwt>. The server negotiates only the marker and never echoes the token.
gRPC
grpcurl -plaintext \
-import-path proto \
-proto getoptimum/optimum_gateway/service/stream/v1/stream.proto \
-H "authorization: Bearer $TOKEN" \
-d '{"mode":"metadata"}' \
GATEWAY_HOST:9601 \
getoptimum.optimum_gateway.service.stream.v1.BlockStreamService/SubscribeEach block is a block frame:
{
"block": {
"slot": "3706300",
"proposerIndex": "535778",
"parentRoot": "saTBS/MRxOtn3FMP5vZGLhMjcgDYoJo5ISec1nGpt8M=",
"stateRoot": "HgD2msizU7N6A6663xIsK5LiauwtPVGTY4Gnbc9mfIg=",
"blockSizeBytes": "17365",
"topic": "/eth2/c6ecb76c/beacon_block/ssz_snappy",
"source": "mump2p",
"receivedAtMs": "1786689003070",
"gatewayId": "ag_...",
"forkDigest": "c6ecb76c"
}
}The stream is open-ended, so stop it yourself: Ctrl-C for WebSocket, or -max-time <seconds> for gRPC. A trailing gRPC DeadlineExceeded is your own timeout expiring, not a server error.
Lag signal
Each connection has a bounded buffer. If a consumer reads too slowly and the buffer overflows, the gateway drops events and sends a lag frame with the cumulative dropped count, then resumes. A slow consumer never stalls the gateway.
{ "type": "lagged", "dropped": 12 }Over gRPC the same signal is a lagged frame: { "lagged": { "dropped": "12" } }.
Errors
Connection-time (gateway WebSocket / gRPC):
| Condition | WebSocket | gRPC |
|---|---|---|
| Missing / bad token | 401 Unauthorized | Unauthenticated |
Invalid mode | 400 Bad Request | InvalidArgument |
| Connection cap reached | 503 Service Unavailable | ResourceExhausted |
Token exchange (POST /api/v1/stream/token on the auth service) is a different call. Unknown, suspended, or revoked osc_ keys fail there as 401 invalid_key — the gateway never sees that status on the stream.
Metrics
Consumer stream series are exported on the telemetry port (telemetry_port, default 48123) when stream_enable is true. See Metrics — Consumer Block Stream for names and types (mump2p_stream_*).
curl -s http://localhost:48123/metrics | grep mump2p_stream_Availability
The stream is live on Hoodi and Mainnet. Mint the consumer key in the Partner Console with the matching network selected, and exchange it at https://auth.getoptimum.io/api/v1/stream/token.
| Network | Console picker | Cluster ID (typical) |
|---|---|---|
| Hoodi | Hoodi | optimum_ethereum_hoodi_v0_1 |
| Mainnet | Mainnet | optimum_ethereum_mainnet_v0_1 |
Use the cluster ID Optimum assigned you at onboarding if it differs from the table.

