Watch-Only Node API
The Watch-Only Node API exposes Hydra App's WatchOnlyNodeService — read-only queries over node identity, channels and payment history on every protocol the node runs, not just Lightning. Nothing here signs or moves funds, so it is the surface to point monitoring and display at.
JSON-RPC namespace: watchOnlyNode
Endpoints
- Get Node ID
- Get Channels
- Get Channels With Counterparty
- Get Channel
- Get Payments
- Get Pending Payments
- Get Payments By Hash
- Get Payment
Get Node ID
Get the node ID for a specific network.
Service: WatchOnlyNodeServiceMethod: GetNodeId
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network to get node ID for |
Response:
| Field | Type | Description |
|---|---|---|
node_id | string | This node's public key on that network, hex-encoded. It is the identity half of the <node_id>@<host>:<port> peer string |
Example Request:
TypeScript
const nodeId = await hydraGrpcClient.getNodeId({
network: { protocol: 1, id: '0a03cf40' } // Bitcoin Signet
})
Rust
let request = tonic::Request::new(GetNodeIdRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
});
let response = client.get_node_id(request).await?;
let node_id = response.into_inner().node_id;
Get Channels
Get all channels for a network.
Service: WatchOnlyNodeServiceMethod: GetChannels
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network to query channels for |
Response:
| Field | Type | Description |
|---|---|---|
channels | Channel[] | Array of channel objects |
Channel Object:
| Field | Type | Description |
|---|---|---|
id | string | Channel identifier |
counterparty | string | Counterparty node ID |
status | ChannelStatus enum | High-level channel status — CHANNEL_STATUS_INACTIVE (1), ..._ACTIVE (2), ..._UPDATING (3), ..._CLOSED (4), ..._CLOSED_REDEEMABLE (5) |
asset_channels | map<string, AssetChannel> | Asset-specific channel data, keyed by asset_id |
A channel holds several assets at once;
statusis the channel-wide summary, and the real per-asset state lives inasset_channels.AssetChannelStatusis a oneof with fourteen arms —cooperatively_opening,opening,cooperatively_updating,updating,cooperatively_closing,closing({ closed_at_block }),force_closing({ force_closed_at_block?, disputer, dispute_deadline? }),closed_redeemable,closed({ force_closed }),inactive,active_sending,active_receiving,active,recovering— so match on it rather than comparing to a single "active" value.
closed.force_closedtells you whether the slot is reusable.falseis a cooperative close: on Lithium the channel slot stays reusable for further deposits, while Lightning destroys the channel on any close.trueis a unilateral / disputed close, and the slot is permanently dead under every protocol — the only way forward is a new channel.
AssetChannel Object:
One entry per asset held in the channel. This is where per-asset balance, usability, and lease state live.
| Field | Type | Description |
|---|---|---|
status | AssetChannelStatus | Detailed status of this asset channel |
balance | OffchainBalance | Off-chain balance breakdown for this asset |
last_onchain_txid | string (optional) | Transaction ID of the last on-chain operation (open, deposit, close, …) |
last_operation_confirmations | uint64 | Confirmations for that last on-chain operation |
last_operation_timestamp | Timestamp | When that last on-chain operation happened |
is_updatable | bool | Whether this asset channel accepts a deposit / withdraw |
is_closable | bool | Whether this asset channel can be closed |
lease_expiry | Timestamp (optional) | (2026-07-12) When the liquidity lease on this asset channel expires. Absent when the asset channel is not leased. |
can_send | bool | (2026-08-03) An outbound payment can be originated or routed over this asset channel right now |
can_receive | bool | (2026-08-03) An inbound payment can be received right now |
funding_credited | bool | (2026-08-03) The funds committed by the most recent on-chain operation are reflected in the balance both peers co-sign |
onchain_operation_in_flight | bool | (2026-08-03) A transaction changing this channel's funding is awaiting confirmation |
Use
can_send/can_receivefor routing decisions rather than inferring usability fromstatusplus balances — they account for the states a raw status check misses.
funding_creditedis notis_updatable. A zero-conf open has its funding credited (so it can carry payments) while still admitting no further on-chain update.
lease_expiryis the cheap way to monitor a lease — it rides along on every channel read, so you don't need a separateliquidity.GetLeaseExpiriespoll. Leases extend automatically with channel usage, so an actively-used channel's expiry moves forward on its own; see Lease API → Request Channel Lease Extension.
Example Request:
TypeScript
const channels = await hydraGrpcClient.getChannels({
protocol: Protocol.BITCOIN,
id: '0a03cf40'
})
channels.forEach(ch => {
console.log(`Channel ${ch.id} with ${ch.counterparty}`)
})
Rust
let request = tonic::Request::new(GetChannelsRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
});
let response = client.get_channels(request).await?;
let channels = response.into_inner().channels;
for channel in channels {
println!("Channel {} with {}", channel.id, channel.counterparty);
}
Get Channels With Counterparty
Get all channels with a specific counterparty node.
Service: WatchOnlyNodeServiceMethod: GetChannelsWithCounterparty
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network to query |
counterparty_pubkey | string | YES | Counterparty node public key, hex-encoded |
Response:
| Field | Type | Description |
|---|---|---|
channels | Channel[] | Channels with this counterparty |
Example Request:
TypeScript
const channels = await hydraGrpcClient.getChannelsWithCounterparty({
network: { protocol: 1, id: '0a03cf40' },
counterpartyPubkey: '02abc123...'
})
Rust
let request = tonic::Request::new(GetChannelsWithCounterpartyRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
counterparty_pubkey: "02abc123...".to_string(),
});
let response = client.get_channels_with_counterparty(request).await?;
let channels = response.into_inner().channels;
Get Channel
Get detailed information about a specific channel.
Service: WatchOnlyNodeServiceMethod: GetChannel
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network the channel is on |
channel_id | string | YES | Channel identifier |
Response:
| Field | Type | Description |
|---|---|---|
channel | Channel | Detailed channel information |
Example Request:
TypeScript
const channel = await hydraGrpcClient.getChannel({
network: {
protocol: Protocol.BITCOIN,
id: '0a03cf40'
},
channelId: 'ch_abc123'
})
console.log(`Channel status: ${channel.status}`)
console.log(`Counterparty: ${channel.counterparty}`)
Rust
let request = tonic::Request::new(GetChannelRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
channel_id: "ch_abc123".to_string(),
});
let response = client.get_channel(request).await?;
let channel = response.into_inner().channel;
Get Payments
Get payment history for a network.
Service: WatchOnlyNodeServiceMethod: GetPayments
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Network to query payments for |
pagination | PaginationRequest | no | limit (clamped to 1..1000, default 100) + cursor. Omit cursor for the first page |
Response:
| Field | Type | Description |
|---|---|---|
payments | Payment[] | Array of payment objects, newest first |
pagination | PaginationResponse | next_cursor (absent/empty at the end) + has_more |
This call is paginated. Without a
paginationblock you get the newest 100 payments, not the whole history. Page by feedingnext_cursorback as the next request'spagination.cursoruntilhas_moreis false.GetPendingPaymentsis not paginated — in-flight payments are a bounded set.
Payment Object:
| Field | Type | Description |
|---|---|---|
id | string | Unique payment identifier |
hash | string? | Hex-encoded payment hash. Present on hashlock payments |
preimage | string? | Hex-encoded preimage. Present once the payment is claimed |
status | PaymentStatus | A oneof — see below |
timestamp | Timestamp | When the payment was initiated |
spent | map<string, DecimalString> | asset_id → total amount this node spent |
received | map<string, DecimalString> | asset_id → total amount this node received |
operations | PaymentOperation[] | The individual legs that make up the payment — see below |
There is no
directionfield, and noamountfield. Direction is implied by whichoperationsarm is set, and by whether the amount lands inspentorreceived. Older revisions of this page listed aPaymentDirectionenum that has never been on the wire.
PaymentStatus — exactly one arm is set:
| Arm | Payload | Meaning |
|---|---|---|
pending | (empty) | Being routed or processed |
pending_preimage | { resolution_deadline: Deadline? } | A hashlock payment awaiting its preimage. The deadline is when it stops being resolvable — see ResolveHashlockPayment |
completed | (empty) | Settled successfully |
expired | (empty) | Ran out of time before completing |
failed | (empty) | Failed on a routing or protocol error |
rejected | (empty) | Explicitly rejected by the recipient — see RejectPayment |
PaymentOperation — also a oneof, one entry per leg:
| Arm | Fields |
|---|---|
send | asset_id, to, recipient_amount, shards[] (SendingPaymentShard: channel_id, counterparty, amount, fee) |
receive | asset_id, from?, recipient_amount, shards[] (ReceivingPaymentShard: channel_id, counterparty, amount) |
route | asset_id, routed_from_channel_id, routed_from_node_id, routed_to_channel_id, routed_to_node_id, amount, earned_fee |
recipient_amountis what the recipient is meant to end up with; theshardsare how it was actually split across channels, and theiramounts include the routing fee. A multi-path payment has one operation with several shards, not several operations.
Example Request:
TypeScript
const { payments, pagination } = await hydraGrpcClient.getPayments({
network: { protocol: 1, id: '0a03cf40' },
pagination: { limit: 100 }
})
for (const payment of payments) {
const [kind] = Object.keys(payment.status) // pending | completed | failed | …
console.log(`${payment.id}: ${kind}`)
}
// pagination.hasMore ⇒ call again with pagination.cursor = pagination.nextCursor
Rust
let request = tonic::Request::new(GetPaymentsRequest {
network: Some(Network {
protocol: Protocol::Bitcoin as i32,
id: "0a03cf40".to_string(),
}),
pagination: Some(PaginationRequest { limit: 100, cursor: None }),
});
let response = client.get_payments(request).await?.into_inner();
for payment in response.payments {
println!("{}: {:?}", payment.id, payment.status);
}
// response.pagination.has_more ⇒ call again with the returned next_cursor
Get Pending Payments
Returns all pending (in-flight) off-chain payments on the specified network.
Method: GetPendingPayments
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
Response:
| Field | Type | Description |
|---|---|---|
payments | Payment[] | Currently in-flight payments |
Example Request:
import { GetPendingPaymentsRequest } from './proto/watch_only_node_pb'
const request = new GetPendingPaymentsRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
const response = await client.getPendingPayments(request, {})
const pending = response.getPaymentsList()
console.log(`${pending.length} pending payments`)
Get Payments By Hash
Returns all payments matching a specific payment hash. Useful for resolving the state of a known invoice or hashlock.
Method: GetPaymentsByHash
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
payment_hash | string | YES | Hex-encoded payment hash |
Response:
| Field | Type | Description |
|---|---|---|
payments | Payment[] | Payments matching the hash |
Example Request:
import { GetPaymentsByHashRequest } from './proto/watch_only_node_pb'
const request = new GetPaymentsByHashRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setPaymentHash('a1b2c3d4...')
const response = await client.getPaymentsByHash(request, {})
console.log(`Found ${response.getPaymentsList().length} payments`)
Get Payment
Returns the details of a specific payment by its unique ID.
Method: GetPayment
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
network | Network | YES | Target network |
payment_id | string | YES | Unique payment identifier |
Response:
| Field | Type | Description |
|---|---|---|
payment | Payment | Requested payment |
Example Request:
import { GetPaymentRequest } from './proto/watch_only_node_pb'
const request = new GetPaymentRequest()
request.setNetwork({ protocol: 1, id: '0a03cf40' })
request.setPaymentId('pmt_abc123')
const response = await client.getPayment(request, {})
console.log('Payment:', response.getPayment()?.toObject())
Common Patterns
Monitor channel status
Rust
async fn get_active_channels(
client: &mut WatchOnlyNodeServiceClient<Channel>,
network: Network,
) -> Result<Vec<Channel>, Box<dyn std::error::Error>> {
let channels = client
.get_channels(GetChannelsRequest {
network: Some(network),
})
.await?
.into_inner()
.channels;
Ok(channels
.into_iter()
.filter(|ch| ch.status == ChannelStatus::Active as i32)
.collect())
}
Find channel with peer
Rust
async fn find_channel_with_peer(
client: &mut WatchOnlyNodeServiceClient<Channel>,
network: Network,
peer_id: String,
) -> Result<Option<Channel>, Box<dyn std::error::Error>> {
let channels = client
.get_channels_with_counterparty(GetChannelsWithCounterpartyRequest {
network: Some(network),
counterparty_node_id: peer_id,
})
.await?
.into_inner()
.channels;
Ok(channels.into_iter().next())
}
Error Handling
| Error Code | Description | Solution |
|---|---|---|
INVALID_ARGUMENT | Invalid network or channel ID | Verify parameters |
NOT_FOUND | Channel or payment not found | Check ID is correct |
UNAVAILABLE | Service temporarily unavailable | Retry with backoff |
Best Practices
- Use this surface for monitoring. Nothing here can move funds, so it is safe to expose to a dashboard that the trading path is not.
- Prefer the event stream to polling.
SubscribeNodeEventspushes channel and payment changes; these calls are for the initial snapshot and for reconciliation. - Judge a channel by
can_send/can_receive/is_updatable, not by the high-levelstatus. A channel can be on-chain confirmed and still unusable for the thing you want to do — see theAssetChannelnotes. - Page
GetPayments. The default page is 100 rows, newest first, not the whole history. - Resolve an invoice through
GetPaymentsByHash, not by scanningGetPayments— a hash can have several attempts behind it, and this returns all of them.