advanced 60 min

Building a custom RPC handler

Implement a new RPC command end-to-end — the handler function, registration and conditions.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Write a handler (`Json::Value doX(RPC::JsonContext&)`).
  • Register it in `kHandlerArray` with role and condition.
  • Read parameters and build a response with `jss` fields.
Complete this module by self-assessment and a quiz. Jump to assessment

Building Your First RPC Handler from Scratch


Introduction

≈60 min · Advanced · builds on RPC authentication & error handling

Time to write something of your own. In this module you'll implement a new RPC command end to end, the handler function, its registration in the handler table with a role and condition, and reading parameters to build a clean response with jss fields. It's the most hands-on module of the RPC phase, and the payoff for everything before it.


Handler Implementation Checklist

In brief: everything a new handler needs before it works end to end.

Before you start coding, here's what you need to prepare:

Define the handler's purpose (What query or operation will it perform? Identify required parameters) What inputs does it need from the client? Determine permission level, What role should be required (USER, ADMIN, etc.)? Specify ledger requirements (Does it need current, validated, or specific ledgers? Plan error scenarios) What could go wrong, and how will you handle it? Design the response format, What data structure will you return?


Step-by-Step Implementation Guide

In brief: write the function, declare it, register it, done.

The anatomy of every good handler, in order: check permissions, validate input, resolve the ledger once, query with bounds, and build the response.

Step 1: Create the Handler File

Create a new file in the handlers directory. Current handler files carry no license banner — they begin directly with their #include lines (open account/AccountInfo.cpp and see). The JSON library lives in the xrpl::json namespace, so inside namespace xrpl you spell it json::Value. Note that RPC::lookupLedger is declared in RPCLedgerHelpers.h, not RPCHelpers.h.

File: src/xrpld/rpc/handlers/MyCustomHandler.cpp

The build picks up new .cpp files under src/xrpld/ automatically (the sources are globbed in cmake/XrplCore.cmake) — re-run CMake after adding the file.

Key idea. A command only becomes reachable once it is registered in the handler table with its role and condition. Writing the function is not enough.


Step 2: Define the Handler Function

Implement the handler with the standard signature:

Two idioms to notice, both lifted straight from doAccountInfo:

  • Errors carry messages via the RPC:: helpers — RPC::missingFieldError(jss::account), RPC::invalidFieldError(jss::account), RPC::makeError(code, message). The old one-argument rpcError(code) still exists in RPCErr.h but is marked deprecated; it cannot carry a message at all.
  • Merge into the result that lookupLedger returned — it already contains ledger_index/ledger_current_index and validated, so you never compute "validated" yourself. Once you have a result, report later errors with RPC::injectError(code, result) so the ledger context survives in the error response.

Step 3: Declare in Handlers.h (Required)

This step is not optional. Handler.cpp builds the handler table from names like &doMyCustomCommand, and the only declarations it sees come from #include <xrpld/rpc/handlers/Handlers.h>. Without this declaration, Step 4 does not compile — doMyCustomCommand is an undeclared identifier in Handler.cpp's translation unit. (A forward declaration inside your own .cpp file is invisible to Handler.cpp.)

File: src/xrpld/rpc/handlers/Handlers.h

json::Value
doMyCustomCommand(RPC::JsonContext&);

Step 4: Register the Handler

Add your handler to the central table, kHandlerArray:

File: src/xrpld/rpc/detail/Handler.cpp

{.name = "my_custom_command",
 .valueMethod = byRef(&doMyCustomCommand),
 .role = Role::USER,
 .condition = Condition::NeedsCurrentLedger},

Understanding the JsonContext

In brief: the object that carries the request, services, and ledger access into your handler.

The JsonContext object is your gateway to Rippled's internals. Here is the real definition, from src/xrpld/rpc/Context.h:

Essential Fields

Two things worth calling out:

  • There is no ledger member. A handler is never handed a ledger — it resolves one itself, via RPC::lookupLedger, from whatever ledger_index / ledger_hash the client sent.
  • loadType is how a handler declares its cost. Expensive handlers set it so the resource manager charges the caller accordingly — doSubmit starts with context.loadType = Resource::kFeeMediumBurdenRpc;.

Accessing Services


Input Validation Patterns

In brief: validate every field the caller sends before you use it.

Proper input validation is critical for security and reliability:

Validate Required Fields

if (!context.params.isMember(jss::account))
    return RPC::missingFieldError(jss::account);

if (!context.params[jss::account].isString())
    return RPC::invalidFieldError(jss::account);

These helpers (declared in <xrpl/protocol/ErrorCodes.h>) produce the standard messages clients already know — "Missing field 'account'.", "Invalid field 'account'." — so every handler reports the same way.

Parse Account Addresses

auto const account = parseBase58<AccountID>(
    context.params[jss::account].asString()
);

if (!account)
    return RPC::makeError(RpcActMalformed);

Validate Numeric Parameters

if (context.params.isMember(jss::limit)) {
    if (!context.params[jss::limit].isUInt())
        return RPC::expectedFieldError(jss::limit, "unsigned integer");

    unsigned int limit = context.params[jss::limit].asUInt();

    if (limit == 0 || limit > 1000)
        return RPC::makeParamError("'limit' must be between 1 and 1000");
}

Validate Currency Codes

if (context.params.isMember(jss::currency)) {
    Currency currency;
    if (!toCurrency(currency, context.params[jss::currency].asString()))
        return RPC::invalidFieldError(jss::currency);
}

(toCurrency is the current spelling — the old to_currency is gone. The two-argument overload returns bool; the one-argument overload returns a Currency and signals failure with noCurrency().)


Ledger Access Patterns

Most handlers need to access ledger data:

Using RPC::lookupLedger

The standard way to get a ledger (declared in <xrpld/rpc/detail/RPCLedgerHelpers.h>):

std::shared_ptr<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);

if (!ledger)
    return result;  // Return the error response

// Now you can safely use 'ledger' — and 'result' already carries
// the ledger context (ledger_index / validated)

This helper function:

  • Parses ledger_index or ledger_hash from params
  • Handles special values like "validated", "current", "closed"
  • Returns appropriate error if ledger not found
  • Populates the ledger shared pointer
  • On success, fills the returned json::Value with the ledger context — jss::ledger_index (or jss::ledger_current_index) and jss::validated — so your handler builds on top of it instead of recomputing those fields

Manual Ledger Selection

For advanced use cases:

std::shared_ptr<ReadView const> ledger;

if (context.params.isMember(jss::ledger_index)) {
    auto const ledgerIndex = context.params[jss::ledger_index].asUInt();
    ledger = context.ledgerMaster.getLedgerBySeq(ledgerIndex);
} else {
    // Default to current ledger
    ledger = context.ledgerMaster.getCurrentLedger();
}

if (!ledger)
    return RPC::makeError(RpcLgrNotFound);

Reading Ledger Objects

Once you have a ledger, you can query its state:

Read an Account

auto const sleAccount = ledger->read(keylet::account(accountID));

if (!sleAccount)
    return RPC::makeError(RpcActNotFound);

// Access account fields
STAmount balance = sleAccount->getFieldAmount(sfBalance);
std::uint32_t sequence = sleAccount->getFieldU32(sfSequence);
AccountID account = sleAccount->getAccountID(sfAccount);

Read Trust Lines

(This is exactly how TrustLine.cpp does it — it reads both fields and selects by side.)

Read Offers

auto const sleOffer = ledger->read(keylet::offer(accountID, sequence));

if (sleOffer) {
    STAmount takerPays = sleOffer->getFieldAmount(sfTakerPays);
    STAmount takerGets = sleOffer->getFieldAmount(sfTakerGets);
}

Iterate Directory

auto const dir = ledger->read(keylet::ownerDir(accountID));

if (dir) {
    for (auto const& index : dir->getFieldV256(sfIndexes)) {
        auto const sle = ledger->read(keylet::child(index));
        // Process each owned object
    }
}

Response Construction

Build well-structured JSON responses:

Basic Response

std::shared_ptr<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);

if (!ledger)
    return result;

// lookupLedger already set jss::ledger_index (or ledger_current_index)
// and jss::validated on `result`; add your fields on top of it.
result[jss::account] = toBase58(accountID);

return result;

Nested Objects

json::Value accountData(json::ValueType::Object);
accountData[jss::Account] = toBase58(accountID);
accountData["Balance"] = balance.getText();
accountData[jss::Sequence] = sequence;

result[jss::account_data] = accountData;

Arrays

json::Value lines(json::ValueType::Array);

for (auto const& line : trustLines) {
    json::Value lineJson(json::ValueType::Object);
    lineJson[jss::account] = to_string(line.account);
    lineJson[jss::balance] = line.balance.getText();
    lineJson[jss::currency] = to_string(line.currency);

    lines.append(lineJson);
}

result[jss::lines] = lines;

(There is no json::arrayValue alias in this tree — spell out json::ValueType::Array and json::ValueType::Object.)


Common Helper Functions

Rippled provides many utility functions to simplify handler implementation:

Account Parsing

// From <xrpl/protocol/AccountID.h> — the same template every handler uses
auto account = parseBase58<AccountID>(accountStr);
if (!account)
    return RPC::makeError(RpcActMalformed);

Amount Parsing

// From <xrpl/protocol/STAmount.h>
STAmount amount;
if (!amountFromJsonNoThrow(amount, context.params[jss::amount]))
    return RPC::invalidFieldError(jss::amount);

Currency/Issuer Extraction

// From <xrpl/protocol/UintTypes.h>
Currency currency;
if (!toCurrency(currency, context.params[jss::currency].asString()))
    return RPC::invalidFieldError(jss::currency);

Error Construction

// From <xrpl/protocol/ErrorCodes.h>
return RPC::makeError(RpcLgrNotFound);                     // code only
return RPC::makeError(RpcNotSupported, "Not supported."); // code + message
return RPC::missingFieldError(jss::account);               // "Missing field 'account'."
RPC::injectError(RpcActNotFound, result);                  // add error to an existing result

Real-World Example: Custom Balance Checker

Let's implement a handler that returns XRP balance with reserve calculations:

Registration (declaration in Handlers.h first, then the table entry):

{.name = "get_account_balance",
 .valueMethod = byRef(&doGetAccountBalance),
 .role = Role::USER,
 .condition = Condition::NeedsCurrentLedger},

Example Request:

{
    "method": "get_account_balance",
    "params": [{
        "account": "rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs",
        "ledger_index": "validated"
    }]
}

Example Response:

{
    "result": {
        "account": "rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs",
        "ledger_index": 12345,
        "validated": true,
        "balance": "1000000000",
        "available_balance": "990000000",
        "base_reserve": "10000000",
        "owner_reserve": "0",
        "owner_count": 0
    }
}

Conclusion

Implementing custom RPC handlers transforms your understanding of Rippled's architecture into practical skills. By following the standard function signature, validating inputs thoroughly, leveraging helper functions, and building structured responses, you create handlers that integrate seamlessly with the existing codebase. The step-by-step process, from file creation through registration to testing, provides a repeatable pattern for extending Rippled's API capabilities with your own functionality.


Reference Guide to Production RPC Handlers

This reference guide walks through three RPC handlers from the rippled codebase. The Submit and ServerInfo listings below are the real implementations (Submit trimmed for length, ServerInfo shown in full); the AccountInfo listing is a simplified sketch of the real handler, which additionally supports ident, signer_lists, and queue.

Each example includes:

  • Code walkthrough
  • Key implementation patterns
  • How it integrates with the broader system
  • Performance and security considerations

1. AccountInfo Handler

Overview

The AccountInfo handler queries account state and returns detailed information about an account on the ledger. It's one of the most commonly used RPC commands and demonstrates fundamental patterns.

Source: src/xrpld/rpc/handlers/account/AccountInfo.cpp

Simplified Sketch

This is a simplified sketch that keeps the real handler's structure and idioms — open the source file for the full version (account flags, signer lists, queue data):

Key Patterns Demonstrated

1. Parameter Validation

if (!params[jss::account].isString())
    return RPC::invalidFieldError(jss::account);
// ...
return RPC::missingFieldError(jss::account);
  • Always check for required parameters before accessing
  • Use predefined JSON string constants (jss::*) for consistency
  • Return early with the standard helper errors if validation fails

2. Ledger Lookup Comes First

std::shared_ptr<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);
if (!ledger)
    return result;  // Forward the error response
  • Use RPC::lookupLedger() to get appropriate ledger
  • Helper function returns error if ledger unavailable
  • The returned result is the base of your response — it already carries ledger_index/ledger_current_index and validated

3. Account Parsing, Errors Injected Into the Result

auto const id = parseBase58<AccountID>(strIdent);
if (!id)
{
    RPC::injectError(RpcActMalformed, result);
    return result;
}
  • Use template function parseBase58<>() for Base58Check decoding
  • Check optional return value before using
  • RPC::injectError adds the error to the existing result, keeping the ledger context in the error response

4. Data Query

auto const sleAccepted = ledger->read(keylet::account(*id));
if (!sleAccepted)
{
    result[jss::account] = toBase58(*id);
    RPC::injectError(RpcActNotFound, result);
    return result;
}
  • Use keylet functions to locate ledger objects
  • Check for null before dereferencing
  • Report the missing account with the standard error code

5. Response Construction

result[jss::account_data] =
    sleAccepted->getJson(JsonOptions::Values::None);
return result;
  • Serialize ledger entries with getJson(JsonOptions::Values::None) (house style spells the option out)
  • Include both requested data and contextual information
  • The ledger context is already on result from lookupLedger

Error Handling

Error Condition Error Code Message
Missing account parameter RpcInvalidParams "Missing field 'account'."
Malformed account address RpcActMalformed "Account malformed."
Ledger not found RpcLgrNotFound "Ledger not found."
Account doesn't exist RpcActNotFound "Account not found."

2. Submit Handler

Overview

The Submit handler processes transaction submission. It demonstrates more complex patterns including resource charging, deserialization, local validity checks, and handing the transaction to the network layer.

Source: src/xrpld/rpc/handlers/transaction/Submit.cpp

Note the registration first — submit is a Role::USER command; any client can submit a pre-signed transaction:

{.name = "submit",
 .valueMethod = byRef(&doSubmit),
 .role = Role::USER,
 .condition = Condition::NeedsCurrentLedger},

Implementation (trimmed)

This is the real doSubmit, trimmed for length:

Key Patterns Demonstrated

1. Resource Charging

context.loadType = Resource::kFeeMediumBurdenRpc;
  • Expensive handlers set context.loadType first, before doing any work
  • The resource manager uses it to throttle abusive clients
  • Refer to the RPC authentication & error handling module

2. Binary Deserialization

auto ret = strUnHex(context.params[jss::tx_blob].asString());
SerialIter sitTrans(makeSlice(*ret));
stTx = std::make_shared<STTx const>(std::ref(sitTrans));
  • Decode hex input with strUnHex, then deserialize with SerialIter
  • Deserialization throws — wrap it in try/catch and report the exception text
  • Validate all inputs before state-changing operations

3. Context-Dependent Validation

auto [validity, reason] = checkValidity(
    context.app.getHashRouter(), *stTx,
    context.ledgerMaster.getCurrentLedger()->rules());
  • Local checks run against the current ledger's rules (active amendments)
  • Provide the failure reason in the error response
  • Only after local checks pass does the transaction go to the network

4. Role-Sensitive Behavior, Not Role Gating

context.netOps.processTransaction(
    transaction, isUnlimited(context.role), true, *failType);
  • The command itself is open to Role::USER — there is no role gate on submission
  • isUnlimited(context.role) only relaxes rate limits for trusted callers
  • The one role check in the handler guards the deprecated sign-and-submit path (context.role != Role::ADMIN && !context.app.config().canSign())

Error Scenarios

Scenario Code / error Response
Undecodable tx_blob RpcInvalidParams 200 OK, error object in the body
Unparseable transaction "invalidTransaction" + exception text 200 OK, error object in the body
Fails local checks "invalidTransaction" + reason 200 OK, error object in the body
Signing requested, not supported RpcNotSupported 200 OK, error object in the body

Note: JSON-RPC over HTTP in rippled returns HTTP 200 with the error inside the JSON body for handler-level failures; non-200 statuses are reserved for transport-level problems (malformed HTTP, unauthorized port, server overload).


3. ServerInfo Handler

Overview

The ServerInfo handler returns comprehensive information about the running node. It demonstrates the opposite extreme from Submit: the handler itself is tiny, because all the work is delegated to NetworkOPs.

Source: src/xrpld/rpc/handlers/server_info/ServerInfo.cpp

Complete Implementation

This is the entire handler, verbatim:

json::Value
doServerInfo(RPC::JsonContext& context)
{
    json::Value ret(json::ValueType::Object);

    ret[jss::info] = context.netOps.getServerInfo(
        true,
        context.role == Role::ADMIN,
        context.params.isMember(jss::counters) && context.params[jss::counters].asBool());

    return ret;
}

NetworkOPs::getServerInfo(bool human, bool admin, bool counters) assembles the whole info object — server state, validated ledger, peer count, load, and (for admins) extra detail.

Key Patterns Demonstrated

1. Delegate Aggregation to the Service That Owns the Data

ret[jss::info] = context.netOps.getServerInfo(true, /* admin */ ..., /* counters */ ...);
  • The handler doesn't collect server state field by field — NetworkOPs owns that data and builds the JSON
  • doServerState (the machine-readable sibling) calls the same function with human = false
  • Keep handlers thin when a service already knows how to describe itself

2. Role-Based Response Modification

context.role == Role::ADMIN
  • The caller's role is passed down as the admin flag; getServerInfo includes sensitive detail only when it is true
  • Include sensitive information only for authorized clients
  • Refer to the RPC authentication & error handling module

3. Optional Request Flags

context.params.isMember(jss::counters) && context.params[jss::counters].asBool()
  • Optional boolean parameters are read defensively: present AND true
  • A missing flag defaults to the cheaper behavior

No Error Handling?

Notably, ServerInfo has no error paths because:

  1. Server state is always available
  2. Missing data is handled inside getServerInfo by omitting fields
  3. The only input (counters) is an optional flag read defensively
  4. No permission that would cause rejection

This demonstrates that not all handlers need error-heavy validation. Only validate what the client provides.


Common Implementation Patterns

Pattern 1: Parameter Extraction and Validation

// Safe extraction with validation
if (!context.params.isMember(jss::account))
    return RPC::missingFieldError(jss::account);

std::string const strAccount =
    context.params[jss::account].asString();

auto const account = parseBase58<AccountID>(strAccount);
if (!account)
    return RPC::makeError(RpcActMalformed);

Pattern 2: Ledger Access

Pattern 3: Permission Checking

// Check permission early
if (context.role != Role::ADMIN)
    return RPC::makeError(RpcNoPermission);

// Now proceed with privileged operations

Pattern 4: Complex Result Building


Performance Considerations

1. Minimize Ledger Queries

// BAD: Multiple reads from ledger
auto sle1 = ledger->read(keylet1);
auto sle2 = ledger->read(keylet2);

// GOOD: Batch when possible, cache results

2. Declare Expensive Work

// Handlers run on the JobQueue's RPC job threads — a slow handler
// occupies one of them. Declare the cost so the resource manager
// charges (and eventually throttles) the caller:
context.loadType = Resource::kFeeMediumBurdenRpc;

Long-running handlers can also run as coroutines (context.coro; the JobQueue side is postCoro), which is how commands like path finding avoid pinning a job thread.

3. Check Permissions Early

// Check before expensive operations
if (!isUnlimited(context.role)) {
    return RPC::makeError(RpcNoPermission);  // Fast fail
}

// Now do expensive work

Testing These Handlers

See Testing RPC Handlers for comprehensive testing strategies.

RPC handlers are not tested by constructing a JsonContext by hand — it's a bundle of live references (Application&, NetworkOPs&, LedgerMaster&, ...) that can't be defaulted. Real handler tests are beast::unit_test suites that spin up a test Env and call the command through the client interface, like src/test/rpc/AccountInfo_test.cpp:



Related Module Sections:

  • Implementing Custom Handlers
  • the RPC authentication & error handling module
  • Testing RPC Handlers

Summary

This module had you build an RPC command end to end. You wrote a handler with the standard signature, declared it in Handlers.h, registered it in the handler table with a role and condition, validated the incoming parameters, looked up the ledger, and built a clean JSON response using jss field names. A command only becomes reachable once it is registered; writing the function alone is not enough.

To remember:

  • Three steps: write doMyCommand(RPC::JsonContext&) returning json::Value, declare it in Handlers.h (required — Handler.cpp compiles against that header), register it in kHandlerArray (role + condition)
  • A handler that is not registered is unreachable: "unknown command" means missing table entry
  • Validate every parameter: presence, type, format (parseBase58<AccountID> for accounts)
  • Use RPC::lookupLedger to resolve ledger_index / ledger_hash arguments safely — and build your response on the result it returns, which already carries ledger_index and validated
  • Read entries with a keylet + view->read(...)
  • Build responses with jss:: field constants
  • Reference implementations to copy from: server_info/ServerInfo.cpp, account/AccountInfo.cpp in src/xrpld/rpc/handlers
  • Watch out: return errors with the RPC::makeError / missingFieldError / invalidFieldError / injectError helpers, never ad-hoc strings; clients dispatch on the code (the one-argument rpcError is deprecated)

Next up. It works! Now make it something a reviewer would sign off. Next: the best practices that separate working handlers from production handlers.

Assignments

0 of 2 complete

XRPL Academy © 2026