Api

Watch-Only Node API

Read-only access to Lightning node data

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 the node ID for a specific network.

Service: WatchOnlyNodeServiceMethod: GetNodeId

Parameters:

NameTypeRequiredDescription
networkNetworkYESNetwork to get node ID for

Response:

FieldTypeDescription
node_idstringThis 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:

NameTypeRequiredDescription
networkNetworkYESNetwork to query channels for

Response:

FieldTypeDescription
channelsChannel[]Array of channel objects

Channel Object:

FieldTypeDescription
idstringChannel identifier
counterpartystringCounterparty node ID
statusChannelStatus enumHigh-level channel status — CHANNEL_STATUS_INACTIVE (1), ..._ACTIVE (2), ..._UPDATING (3), ..._CLOSED (4), ..._CLOSED_REDEEMABLE (5)
asset_channelsmap<string, AssetChannel>Asset-specific channel data, keyed by asset_id

A channel holds several assets at once; status is the channel-wide summary, and the real per-asset state lives in asset_channels. AssetChannelStatus is 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_closed tells you whether the slot is reusable. false is a cooperative close: on Lithium the channel slot stays reusable for further deposits, while Lightning destroys the channel on any close. true is 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.

FieldTypeDescription
statusAssetChannelStatusDetailed status of this asset channel
balanceOffchainBalanceOff-chain balance breakdown for this asset
last_onchain_txidstring (optional)Transaction ID of the last on-chain operation (open, deposit, close, …)
last_operation_confirmationsuint64Confirmations for that last on-chain operation
last_operation_timestampTimestampWhen that last on-chain operation happened
is_updatableboolWhether this asset channel accepts a deposit / withdraw
is_closableboolWhether this asset channel can be closed
lease_expiryTimestamp (optional)(2026-07-12) When the liquidity lease on this asset channel expires. Absent when the asset channel is not leased.
can_sendbool(2026-08-03) An outbound payment can be originated or routed over this asset channel right now
can_receivebool(2026-08-03) An inbound payment can be received right now
funding_creditedbool(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_flightbool(2026-08-03) A transaction changing this channel's funding is awaiting confirmation

Use can_send / can_receive for routing decisions rather than inferring usability from status plus balances — they account for the states a raw status check misses.

funding_credited is not is_updatable. A zero-conf open has its funding credited (so it can carry payments) while still admitting no further on-chain update.

lease_expiry is the cheap way to monitor a lease — it rides along on every channel read, so you don't need a separate liquidity.GetLeaseExpiries poll. 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:

NameTypeRequiredDescription
networkNetworkYESNetwork to query
counterparty_pubkeystringYESCounterparty node public key, hex-encoded

Response:

FieldTypeDescription
channelsChannel[]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:

NameTypeRequiredDescription
networkNetworkYESNetwork the channel is on
channel_idstringYESChannel identifier

Response:

FieldTypeDescription
channelChannelDetailed 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:

NameTypeRequiredDescription
networkNetworkYESNetwork to query payments for
paginationPaginationRequestnolimit (clamped to 1..1000, default 100) + cursor. Omit cursor for the first page

Response:

FieldTypeDescription
paymentsPayment[]Array of payment objects, newest first
paginationPaginationResponsenext_cursor (absent/empty at the end) + has_more

This call is paginated. Without a pagination block you get the newest 100 payments, not the whole history. Page by feeding next_cursor back as the next request's pagination.cursor until has_more is false. GetPendingPayments is not paginated — in-flight payments are a bounded set.

Payment Object:

FieldTypeDescription
idstringUnique payment identifier
hashstring?Hex-encoded payment hash. Present on hashlock payments
preimagestring?Hex-encoded preimage. Present once the payment is claimed
statusPaymentStatusA oneof — see below
timestampTimestampWhen the payment was initiated
spentmap<string, DecimalString>asset_id → total amount this node spent
receivedmap<string, DecimalString>asset_id → total amount this node received
operationsPaymentOperation[]The individual legs that make up the payment — see below

There is no direction field, and no amount field. Direction is implied by which operations arm is set, and by whether the amount lands in spent or received. Older revisions of this page listed a PaymentDirection enum that has never been on the wire.

PaymentStatus — exactly one arm is set:

ArmPayloadMeaning
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:

ArmFields
sendasset_id, to, recipient_amount, shards[] (SendingPaymentShard: channel_id, counterparty, amount, fee)
receiveasset_id, from?, recipient_amount, shards[] (ReceivingPaymentShard: channel_id, counterparty, amount)
routeasset_id, routed_from_channel_id, routed_from_node_id, routed_to_channel_id, routed_to_node_id, amount, earned_fee

recipient_amount is what the recipient is meant to end up with; the shards are how it was actually split across channels, and their amounts 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:

NameTypeRequiredDescription
networkNetworkYESTarget network

Response:

FieldTypeDescription
paymentsPayment[]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:

NameTypeRequiredDescription
networkNetworkYESTarget network
payment_hashstringYESHex-encoded payment hash

Response:

FieldTypeDescription
paymentsPayment[]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:

NameTypeRequiredDescription
networkNetworkYESTarget network
payment_idstringYESUnique payment identifier

Response:

FieldTypeDescription
paymentPaymentRequested 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 CodeDescriptionSolution
INVALID_ARGUMENTInvalid network or channel IDVerify parameters
NOT_FOUNDChannel or payment not foundCheck ID is correct
UNAVAILABLEService temporarily unavailableRetry with backoff

Best Practices

  1. 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.
  2. Prefer the event stream to polling. SubscribeNodeEvents pushes channel and payment changes; these calls are for the initial snapshot and for reconciliation.
  3. Judge a channel by can_send / can_receive / is_updatable, not by the high-level status. A channel can be on-chain confirmed and still unusable for the thing you want to do — see the AssetChannel notes.
  4. Page GetPayments. The default page is 100 rows, newest first, not the whole history.
  5. Resolve an invoice through GetPaymentsByHash, not by scanning GetPayments — a hash can have several attempts behind it, and this returns all of them.

← Back to API Reference | Next: Events & Subscriptions →


Copyright © 2025