JSON-RPC Interface
Hydra App speaks JSON-RPC 2.0 on the same port as the gRPC server. Use it from any HTTP client without generating protobuf stubs.
If you can run a gRPC client (Node, Go, Rust, Python), prefer that — it's typed and has streaming. JSON-RPC is the right fit for shell scripts, lightweight integrations, debugging, and languages without good gRPC tooling.
Overview
| Property | Value |
|---|---|
| Protocol | JSON-RPC 2.0 |
| Transport | HTTP POST |
| URL | http://<host>:<settings.server_port> (default http://127.0.0.1:5003) |
| Path | Anything — /, /jsonrpc, /rpc all route to the same handler |
| Content-Type | application/json |
| Streaming methods | Available as WebSocket subscriptions on the same URL. Over plain HTTP POST they return -32603 Internal error — a one-shot request cannot carry a subscription. |
Examples on this page assume http://127.0.0.1:5003. If you're going through an SSH tunnel, replace with whatever local port the tunnel forwards.
Quick start
curl -s -X POST http://127.0.0.1:5003 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"app_getNetworks"}'
Response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"networks": [
{ "protocol": "PROTOCOL_BITCOIN", "id": "0a03cf40" },
{ "protocol": "PROTOCOL_EVM", "id": "11155111" },
{ "protocol": "PROTOCOL_EVM", "id": "421614" }
]
}
}
Params shape — important
Hydra App's JSON-RPC handler uses OpenRPC by-name params. There are two valid forms; bare object params don't work.
{
"jsonrpc": "2.0",
"id": 1,
"method": "wallet_getBalances",
"params": [
{ "network": { "protocol": "PROTOCOL_BITCOIN", "id": "0a03cf40" } }
]
}
The third form returns
-32602 Invalid params: missing field \request`. Use positional — it's idiomatic JSON-RPC and matches whatrpc.discover` advertises.The envelope key is
request, on every method that takes params —rpc.discovernames it that way for all 165 of them. (Earlier revisions of this page saidreq; that never parsed.)
For methods with no params (app_getNetworks, app_getPublicKey, orderbook_cancelAllOrders, orderbook_getInitializedMarkets, etc.) you can omit the params field entirely or send [].
Enum encoding
Proto enum values are accepted as either the full constant name (string) or the numeric value (int):
// Both of these work:
{ "protocol": "PROTOCOL_BITCOIN", "id": "0a03cf40" }
{ "protocol": 1, "id": "0a03cf40" }
Responses use the string form. If you compare returned values, expect "PROTOCOL_BITCOIN" not 1.
| Enum | String value | Int value |
|---|---|---|
Protocol | PROTOCOL_BITCOIN | 1 |
Protocol | PROTOCOL_EVM | 2 |
Protocol | PROTOCOL_TRON | 3 |
OrderSide | ORDER_SIDE_BUY | 1 |
OrderSide | ORDER_SIDE_SELL | 2 |
See the proto files in /proto for the complete enum tables.
Timestamp encoding
A protobuf google.protobuf.Timestamp serializes over JSON-RPC as an RFC 3339 string, not as a { seconds, nanos } object:
"createdAt": "2026-08-19T09:39:55Z"
(Corrected 2026-08-19.) The shared OpenRPC schema previously described these as
{ seconds, nanos }objects. That was wrong —pbjsonhas always emitted RFC 3339 strings, and the JSON shapes are now pinned by tests. If you wrote a decoder against the schema rather than against a live response, it needs the string form.
Parse with your language's RFC 3339 reader (Date.parse / chrono::DateTime::parse_from_rfc3339 / time.RFC3339). Every timestamp field on every service uses this encoding — expires_at, created_at, redeemed_at, measured_at, lease_expiry, and the event timestamps.
Wire encoding
JSON-RPC bodies follow the protobuf JSON mapping, which differs from the proto in six ways worth knowing before you write a decoder. Every per-service page in this reference names fields as the proto spells them; this table is how to get from there to the wire.
| Proto | On the wire | Note |
|---|---|---|
snake_case field | camelCase key | Responses always camelCase. Requests accept either, so payment_hash and paymentHash both parse |
DecimalString / U256String | { "value": "1.5" } | A wrapper message, not a bare string. "1.5" where an object is expected is rejected |
uint64 / int64 | JSON string — "212345" | 64-bit integers do not survive a JavaScript number. uint32 / int32 stay JSON numbers |
bytes | base64 string | Standard base64 with padding |
| enum | the constant name — "PROTOCOL_BITCOIN" | The int form is accepted on input; output is always the name |
google.protobuf.Timestamp | RFC 3339 string | Not { seconds, nanos } |
oneof | a single key naming the set arm | { "medium": {} }, { "exact": { "amount": { "value": "1" } } } |
| a field at its default | the key is absent | 0, "", false, an empty list or map are all omitted. Read a missing key as the default, never as an error |
An empty message — the arm of a marker oneof, ReleaseDepositAddressResponse — is {}.
(Corrected 2026-09-11.) Earlier revisions of this page showed a
"created_at"timestamp key and several per-service pages wrote amounts as bare strings. Both were wrong: keys are camelCase and amounts are{ "value": … }objects.rpc.discoverhas always advertised the real shapes.
Discovering methods
Every Hydra App build exposes the full OpenRPC schema via rpc.discover:
curl -s -X POST http://127.0.0.1:5003 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"rpc.discover"}' \
| jq '.result.methods | length'
# → 173
The result includes every method's JSON-Schema for params and result. This is the authoritative reference — it always matches the proto your app was built from. Use it to discover param fields, type structures, and return shapes:
# Pretty-print the schema for one method
curl -s -X POST http://127.0.0.1:5003 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"rpc.discover"}' \
| jq '.result.methods[] | select(.name == "wallet_getBalances")'
Naming convention
Methods follow <service>_<methodCamelCase>. The service name is the lowercase JSON-RPC namespace (the same one shown in each per-service doc page); the method name is the gRPC method in lowerCamelCase.
| gRPC | JSON-RPC |
|---|---|
AppService.GetNetworks | app_getNetworks |
WalletService.GetBalances | wallet_getBalances |
OrderbookService.CreateOrder | orderbook_createOrder |
LiquidityService.RequestChannelLiquidity | liquidity_requestChannelLiquidity |
WatchOnlyNodeService.GetChannel | watchOnlyNode_getChannel |
The
liquiditynamespace is the renamed rental → lease flow; older docs that mentionrental_*methods are out of date — those methods don't exist on current builds.
Field-name conversion
Proto fields use snake_case; JSON-RPC responses use camelCase (requests accept both):
| Proto field | JSON field |
|---|---|
payment_request | paymentRequest |
lease_duration_seconds | leaseDurationSeconds |
priority_fee_per_unit | priorityFeePerUnit |
The rest of the mapping — wrappers, 64-bit integers, bytes, enums, oneofs, omitted defaults — is in Wire encoding. When in doubt, run rpc.discover for the method and read the schema.
Worked examples
Examples below are verified against a live Hydra App on Bitcoin Signet + Ethereum Sepolia + Arbitrum Sepolia. Copy verbatim and substitute your endpoint port if different from 5003.
Get balances on Bitcoin Signet
curl -s -X POST http://127.0.0.1:5003 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "wallet_getBalances",
"params": [
{ "network": { "protocol": "PROTOCOL_BITCOIN", "id": "0a03cf40" } }
]
}' | jq
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"balances": {
"0x0000000000000000000000000000000000000000000000000000000000000000": {
"onchain": {
"confirmed": { "value": "1.66883943" },
"trustedPending": { "value": "0.00000000" },
"pending": { "value": "0.00000000" }
},
"offchain": {
"freeLocal": { "value": "0.24623440" },
"freeRemote": { "value": "0.24376560" },
"pendingLocal": { "value": "0" },
"pendingRemote": { "value": "0" },
"unavailableLocal": { "value": "0" },
"unavailableRemote": { "value": "0" },
"payingLocal": { "value": "0.00000000" },
"payingRemote": { "value": "0.00000000" },
"unspendableLocalReserve": { "value": "0.00500660" },
"unspendableRemoteReserve":{ "value": "0.00499340" },
"redeemableLocal": { "value": "0" },
"redeemableRemote": { "value": "0" },
"maxSendable": { "value": "0.16000000" },
"maxReceivable": { "value": "0.24376560" }
}
}
}
}
}
Map keys are asset IDs — the protocol's zero address for a native asset,
erc20:0x…/trc20:T…for a token. All values areDecimalString— human-readable BTC, not satoshis. See Common Patterns: Amounts & Decimals and Asset ID formats.Note
maxSendablebelowfreeLocal: this channel's value grew past the per-payment ceiling negotiated at open. Size single payments offmaxSendable.
Get the latest block number on Sepolia
curl -s -X POST http://127.0.0.1:5003 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "blockchain_getBlockNumber",
"params": [
{ "network": { "protocol": "PROTOCOL_EVM", "id": "11155111" } }
]
}'
# → {"jsonrpc":"2.0","id":2,"result":{"blockNumber":"10755913"}}
Get a Signet deposit address
curl -s -X POST http://127.0.0.1:5003 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "wallet_getDepositAddress",
"params": [
{ "network": { "protocol": "PROTOCOL_BITCOIN", "id": "0a03cf40" } }
]
}'
# → {"jsonrpc":"2.0","id":3,"result":{"address":"tb1p..."}}
Get the application's public key
curl -s -X POST http://127.0.0.1:5003 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":4,"method":"app_getPublicKey"}'
# → {"jsonrpc":"2.0","id":4,"result":{"publicKey":"<base64-32-bytes>"}}
Place a market sell order on the BTC/USDC pair
Streaming the fill confirmation is not available on JSON-RPC — for that you'll need to call
orderbook_subscribeMarketEventsover gRPC. The example below just places the order; the order ID it returns can be polled withorderbook_getOrder.
curl -s -X POST http://127.0.0.1:5003 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 5,
"method": "orderbook_createOrder",
"params": [{
"orderVariant": {
"marketOrder": {
"base": { "protocol": "PROTOCOL_BITCOIN", "networkId": "0a03cf40", "assetId": "0x0000000000000000000000000000000000000000000000000000000000000000" },
"quote": { "protocol": "PROTOCOL_EVM", "networkId": "11155111", "assetId": "erc20:0x8cd0da3d001b013336918b8bc4e56d9dda1347e0" },
"side": "ORDER_SIDE_SELL",
"amount": { "base": { "amount": { "value": "0.0005" } } }
}
}
}]
}'
# → {"jsonrpc":"2.0","id":5,"result":{"orderId":"..."}}
Provision a service-backed channel via the Lease API
curl -s -X POST http://127.0.0.1:5003 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 6,
"method": "liquidity_requestChannelLiquidity",
"params": [{
"network": { "protocol": "PROTOCOL_BITCOIN", "id": "0a03cf40" },
"operation": {
"open": {
"assetLiquidity": {
"0x0000000000000000000000000000000000000000000000000000000000000000": {
"serverAmount": { "value": "0" },
"clientAmount": { "value": "0.001" }
}
}
}
},
"paymentNetwork": { "protocol": "PROTOCOL_BITCOIN", "id": "0a03cf40" },
"paymentAssetId": "0x0000000000000000000000000000000000000000000000000000000000000000",
"offchainFeePayment": {}
}]
}'
# → {"jsonrpc":"2.0","id":6,"result":{"txid":"...","channelId":"..."}}
Idiomatic clients
class HydraClient {
private nextId = 0
constructor(private url = 'http://127.0.0.1:5003') {}
async call<R = unknown>(method: string, ...positionalArgs: unknown[]): Promise<R> {
const body = {
jsonrpc: '2.0',
id: ++this.nextId,
method,
...(positionalArgs.length ? { params: positionalArgs } : {}),
}
const resp = await fetch(this.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await resp.json()
if (json.error) {
const e = new Error(`${json.error.code}: ${json.error.message}`)
;(e as any).rpcError = json.error
throw e
}
return json.result as R
}
}
const hydra = new HydraClient()
const { networks } = await hydra.call<{ networks: Array<{ protocol: string; id: string }> }>(
'app_getNetworks',
)
console.log(networks)
const balances = await hydra.call(
'wallet_getBalances',
{ network: { protocol: 'PROTOCOL_BITCOIN', id: '0a03cf40' } },
)
console.log(balances)
Subscriptions over WebSocket
Every Subscribe* RPC is also a JSON-RPC subscription. Open a WebSocket to the same URL you POST to — same host, same settings.server_port, no separate endpoint — and the server upgrades the connection.
Over plain HTTP POST a subscribe call returns
-32603 Internal error. That is not the method missing; it is a one-shot request being unable to carry a stream. Switch the transport, not the method.
The three method names
Each subscription has a subscribe name, an unsubscribe name, and a notification name. Hydra App leaves the notification name equal to the subscribe name, so there are only two names to remember: unsubscribe replaces the leading subscribe.
| Subscribe | Unsubscribe | Params | Item |
|---|---|---|---|
event_subscribeClientEvents | event_unsubscribeClientEvents | { network } | ClientEvent |
event_subscribeNodeEvents | event_unsubscribeNodeEvents | { network } | NodeEvent |
event_subscribeHtlcEvents | event_unsubscribeHtlcEvents | { network } | Htlc |
orderbook_subscribeMarketEvents | orderbook_unsubscribeMarketEvents | { base, quote } | MarketEvent |
orderbook_subscribeDexEvents | orderbook_unsubscribeDexEvents | none | DexEvent |
swap_subscribeSimpleSwaps | swap_unsubscribeSimpleSwaps | none | SimpleSwapUpdate |
app_subscribeArchivePruneEvents | app_unsubscribeArchivePruneEvents | none | ArchivePruneEvent |
Params follow the same rules as any other call — positional is the form to use.
The exchange
1. Subscribe. Send:
{
"jsonrpc": "2.0",
"id": 1,
"method": "event_subscribeClientEvents",
"params": [{ "network": { "protocol": "PROTOCOL_BITCOIN", "id": "0a03cf40" } }]
}
The result is your subscription id:
{ "jsonrpc": "2.0", "id": 1, "result": 8043572901134782 }
2. Events arrive as notifications — no id, a method equal to the subscribe name, and the payload under params.result:
{
"jsonrpc": "2.0",
"method": "event_subscribeClientEvents",
"params": {
"subscription": 8043572901134782,
"result": {
"balanceUpdate": {
"assetId": "0x0000000000000000000000000000000000000000000000000000000000000000",
"balance": { "onchain": { "confirmed": { "value": "0.5" } } }
}
}
}
}
3. Unsubscribe by passing the id back positionally:
{ "jsonrpc": "2.0", "id": 2, "method": "event_unsubscribeClientEvents", "params": [8043572901134782] }
{ "jsonrpc": "2.0", "id": 2, "result": true }
Route byparams.subscription, not bymethodOne socket carries every subscription you open on it, and two subscriptions to the same RPC —
event_subscribeClientEventson Bitcoin and on Arbitrum — share amethodname. The subscription id is the only thing that tells them apart. Keep a map from id to handler and dispatch onparams.subscription.The id is a number, not a string. Don't round-trip it through a JS
numberif you need it as a map key at full precision — read it as a string from the raw frame, or key on its decimal text.
Working example
// Node: npm i ws
import WebSocket from 'ws'
const ws = new WebSocket('ws://127.0.0.1:5003')
const handlers = new Map() // subscription id → handler
let nextId = 0
const call = (method, ...params) => new Promise((resolve) => {
const id = ++nextId
handlers.set(`rpc:${id}`, resolve)
ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, ...(params.length ? { params } : {}) }))
})
ws.on('message', (raw) => {
const msg = JSON.parse(raw)
if (msg.id !== undefined) { // a response
handlers.get(`rpc:${msg.id}`)?.(msg.result)
handlers.delete(`rpc:${msg.id}`)
return
}
// a notification — dispatch on the subscription id, never on msg.method
handlers.get(String(msg.params.subscription))?.(msg.params.result)
})
ws.on('open', async () => {
const subId = await call('event_subscribeClientEvents',
{ network: { protocol: 'PROTOCOL_BITCOIN', id: '0a03cf40' } })
handlers.set(String(subId), (evt) => {
if (evt.synced) console.log('chain synced')
if (evt.balanceUpdate) console.log('balance', evt.balanceUpdate.assetId)
})
// …later
// await call('event_unsubscribeClientEvents', subId)
})
Lifecycle
The stream semantics are the same as gRPC's — initial state replay, at-most-once delivery while attached, no resume cursor — so the Streaming guide applies verbatim: subscribe before you act, make handlers idempotent, and reconnect with backoff.
Two differences specific to this transport:
- A dropped socket takes every subscription on it with it. Re-subscribe to all of them after reconnecting; the ids from the old socket are dead.
- Unsubscribe when you stop caring. Closing the socket does it too, but a long-lived socket with abandoned subscriptions keeps the server producing events nobody reads.
If your language has decent gRPC tooling, prefer gRPC for streams — it is typed, and one stream per RPC needs no id-routing of your own. This transport exists so a shell script or a browser can follow a swap without codegen.
Batched requests
JSON-RPC 2.0 batching is supported — send an array of requests, get an array of responses (order may differ; match by id).
curl -s -X POST http://127.0.0.1:5003 \
-H "Content-Type: application/json" \
-d '[
{"jsonrpc":"2.0","id":1,"method":"app_getNetworks"},
{"jsonrpc":"2.0","id":2,"method":"app_getPublicKey"}
]'
Errors
Standard JSON-RPC 2.0 error envelope:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32602,
"message": "Invalid params",
"data": "missing field `req` at line 1 column 42"
}
}
| Code | Meaning | What it usually indicates for Hydra |
|---|---|---|
-32700 | Parse error | Malformed JSON |
-32600 | Invalid Request | Missing jsonrpc / method |
-32601 | Method not found | Method name is wrong (typo, or a method that's only on a newer build — check rpc.discover), or the method exists but is unsupported on the network you passed (a token call on Bitcoin, say) |
-32602 | Invalid params | Wrong shape — most common cause is a flat {...} instead of [{...}] or { "req": {...} }; second most common is a missing required field |
-32603 | Internal error | Server-side problem; check Hydra App logs |
Application-level errors come back in the implementation-defined -32000 to -32099 range. Each is a deterministic translation of the gRPC status the same call would have returned, so the retry guidance in Errors applies unchanged:
| gRPC status | JSON-RPC code |
|---|---|
PERMISSION_DENIED | -32000 |
UNAUTHENTICATED, CANCELLED | -32001 |
DEADLINE_EXCEEDED | -32002 |
RESOURCE_EXHAUSTED | -32003 |
NOT_FOUND | -32004 |
ALREADY_EXISTS | -32005 |
ABORTED | -32006 |
UNAVAILABLE | -32008 |
FAILED_PRECONDITION | -32010 |
INVALID_ARGUMENT, OUT_OF_RANGE | -32602 |
UNIMPLEMENTED | -32601 |
INTERNAL, UNKNOWN, DATA_LOSS | -32603 |
-32010 is the one you will see most: it is every runtime precondition — insufficient balance, channel not active, peer unreachable, lease provider not initialized. Example:
{ "jsonrpc": "2.0", "id": 6,
"error": { "code": -32010, "message": "Liquidity manager not initialized" } }
The message mirrors the underlying gRPC status text. See Errors for retry guidance per gRPC status — the same rules apply here.
See also
- Setup Guide — including how
server_portmaps to the JSON-RPC URL - Bot Quickstart — same flow over native gRPC
- Common Patterns —
DecimalString, fee structures, amounts - Errors — full gRPC status code catalog with retry guidance
rpc.discover— authoritative method reference baked into every Hydra App build