Api

JSON-RPC Interface

Call Hydra App over plain HTTP+JSON, no protobuf code generation

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

PropertyValue
ProtocolJSON-RPC 2.0
TransportHTTP POST
URLhttp://<host>:<settings.server_port> (default http://127.0.0.1:5003)
PathAnything — /, /jsonrpc, /rpc all route to the same handler
Content-Typeapplication/json
Streaming methodsAvailable 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 what rpc.discover` advertises.

The envelope key is request, on every method that takes params — rpc.discover names it that way for all 165 of them. (Earlier revisions of this page said req; 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.

EnumString valueInt value
ProtocolPROTOCOL_BITCOIN1
ProtocolPROTOCOL_EVM2
ProtocolPROTOCOL_TRON3
OrderSideORDER_SIDE_BUY1
OrderSideORDER_SIDE_SELL2

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 — pbjson has 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.

ProtoOn the wireNote
snake_case fieldcamelCase keyResponses 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 / int64JSON string"212345"64-bit integers do not survive a JavaScript number. uint32 / int32 stay JSON numbers
bytesbase64 stringStandard base64 with padding
enumthe constant name — "PROTOCOL_BITCOIN"The int form is accepted on input; output is always the name
google.protobuf.TimestampRFC 3339 stringNot { seconds, nanos }
oneofa single key naming the set arm{ "medium": {} }, { "exact": { "amount": { "value": "1" } } }
a field at its defaultthe key is absent0, "", 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.discover has 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.

gRPCJSON-RPC
AppService.GetNetworksapp_getNetworks
WalletService.GetBalanceswallet_getBalances
OrderbookService.CreateOrderorderbook_createOrder
LiquidityService.RequestChannelLiquidityliquidity_requestChannelLiquidity
WatchOnlyNodeService.GetChannelwatchOnlyNode_getChannel

The liquidity namespace is the renamed rental → lease flow; older docs that mention rental_* 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 fieldJSON field
payment_requestpaymentRequest
lease_duration_secondsleaseDurationSeconds
priority_fee_per_unitpriorityFeePerUnit

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 are DecimalString — human-readable BTC, not satoshis. See Common Patterns: Amounts & Decimals and Asset ID formats.

Note maxSendable below freeLocal: this channel's value grew past the per-payment ceiling negotiated at open. Size single payments off maxSendable.

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_subscribeMarketEvents over gRPC. The example below just places the order; the order ID it returns can be polled with orderbook_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.

SubscribeUnsubscribeParamsItem
event_subscribeClientEventsevent_unsubscribeClientEvents{ network }ClientEvent
event_subscribeNodeEventsevent_unsubscribeNodeEvents{ network }NodeEvent
event_subscribeHtlcEventsevent_unsubscribeHtlcEvents{ network }Htlc
orderbook_subscribeMarketEventsorderbook_unsubscribeMarketEvents{ base, quote }MarketEvent
orderbook_subscribeDexEventsorderbook_unsubscribeDexEventsnoneDexEvent
swap_subscribeSimpleSwapsswap_unsubscribeSimpleSwapsnoneSimpleSwapUpdate
app_subscribeArchivePruneEventsapp_unsubscribeArchivePruneEventsnoneArchivePruneEvent

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 by params.subscription, not by method

One socket carries every subscription you open on it, and two subscriptions to the same RPC — event_subscribeClientEvents on Bitcoin and on Arbitrum — share a method name. The subscription id is the only thing that tells them apart. Keep a map from id to handler and dispatch on params.subscription.

The id is a number, not a string. Don't round-trip it through a JS number if 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"
  }
}
CodeMeaningWhat it usually indicates for Hydra
-32700Parse errorMalformed JSON
-32600Invalid RequestMissing jsonrpc / method
-32601Method not foundMethod 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)
-32602Invalid paramsWrong shape — most common cause is a flat {...} instead of [{...}] or { "req": {...} }; second most common is a missing required field
-32603Internal errorServer-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 statusJSON-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_port maps to the JSON-RPC URL
  • Bot Quickstart — same flow over native gRPC
  • Common PatternsDecimalString, fee structures, amounts
  • Errors — full gRPC status code catalog with retry guidance
  • rpc.discover — authoritative method reference baked into every Hydra App build

Copyright © 2025