JSON-RPC 2.0 is the invisible standard powering everything from blockchain networks to modern AI agents. In this five-part series, we’ll move beyond the spec to build, debug, and deploy real JSON-RPC systems — starting with the exact structure of the messages themselves.
This is a JSON-RPC 2.0 request. It asks an Ethereum node for an account balance:
{
"jsonrpc": "2.0",
"method": "eth_getBalance",
"params": ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "latest"],
"id": 1
}
And this is the response:
{
"jsonrpc": "2.0",
"result": "0x1bdb569fa3c2565cc", // ~32.11 ETH
"id": 1
}
Four fields. No headers, no URL patterns, no HTTP verbs. That's the entire protocol surface. Every MetaMask transaction, every VSCode autocomplete suggestion, every AI agent tool call through MCP — they're all carried by this structure.
Try it yourself
You can test this right now against a public Ethereum node. Note that while JSON-RPC is transport-agnostic, HTTP implementations require Content-Type: application/json.
curl -X POST \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_getBalance","params":["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "latest"],"id":1}' \
https://ethereum.publicnode.com
Decoding the Result
The result is a hexadecimal integer in Wei (1 ETH = 10^18 Wei).
Example verification:
- Hex:
0x1bdb569fa3c2565cc - Decimal:
32,116,692,841,134,384,588(Converter) - ETH:
~32.1166 ETH(Eth-Converter) - USD:
~$63,677(varies by price)
Compare this with the live balance on Etherscan.
The JSON-RPC 2.0 specification is roughly 2,000 words. You can read it during lunch. But those 2,000 words contain rules that are easy to miss and expensive to get wrong: mutual exclusivity constraints, a notification system, and an error code scheme.
This article takes the spec apart, field by field. By the end, you'll be able to construct, validate, and debug any JSON-RPC message without reaching for documentation.
1.0 → 2.0: What changed and why
JSON-RPC 1.0 (2005) was a loose convention. It worked, but it had real problems: no standardized error codes, only positional parameters (made API evolution fragile and often breaking for existing clients.), and no way to distinguish protocol versions. The 2.0 spec (finalized 2010) fixed these with six specific additions:
| Feature | 1.0 | 2.0 |
|---|---|---|
| Version field | — | "jsonrpc": "2.0" required |
| Parameters | Positional only (Array) | Positional OR Named (Object) |
| Error codes | Not standardized | Reserved -32xxx range |
| Notifications | id: null |
id absent |
| Batch | Not defined | Array of requests |
| Result/Error | Both present, one null | Mutually exclusive |
Beyond the message format, 2.0 also formalized the architecture: 1.0 was peer-to-peer (anyone can ask anyone), while 2.0 is strictly client-server. The rest of this article covers 2.0 exclusively.
1. The Request Object
A Request is a JSON object sent by the client to invoke a method on the server. It has four members — two required, two optional:
{
"jsonrpc": "2.0", // Required. Must be exactly "2.0".
"method": "subtract", // Required. String. The method to call.
"params": [42, 23], // Optional. Array OR Object.
"id": 1 // Optional. String, Number, or Null.
}
1.1 jsonrpc — the version handshake
Must be exactly the string "2.0". Not the number 2.0. Not "2". The exact string "2.0".
This field serves two purposes. First, it allows servers to handle multiple protocol versions. Second, it acts as a quick validity check — if this field is missing or wrong, the server rejects the request immediately with -32600 Invalid Request without spending time on anything else.
Every request, every notification, every item in a batch must include it. No exceptions.
1.2 method — the routing mechanism
A string containing the name of the method to invoke. This is how JSON-RPC routes requests — unlike REST, which routes by URL path and HTTP verb, JSON-RPC sends everything to a single endpoint and routes by this string.
Three things to know:
- It's case-sensitive.
"user.Create"and"user.create"are different methods. The spec is explicit about this. - The
rpc.prefix is reserved. Method names starting withrpc.followed by a period are reserved for system extensions. The most important example isrpc.discover(from the OpenRPC specification), which returns a machine-readable schema of all available methods — similar to Swagger/OpenAPI for REST. - Use namespacing. Ethereum uses
eth_getBalance,net_version,admin_peers. MCP usestools/list,tools/call. Pick a convention (namespace.method,namespace_method, ornamespace/method) and be consistent. It prevents collisions and makes your API scannable.
1.3 params — positional vs. named
This is optional. If the method takes no arguments, you can omit it entirely. When present, it must be one of two types — an Array (positional) or an Object (named). Never both. Never a primitive.
Positional parameters are compact. The server maps values by position:
"params": [42, 23]
// Server interprets as: subtract(minuend=42, subtrahend=23)
Named parameters are self-documenting. The server maps values by key:
"params": { "minuend": 42, "subtrahend": 23 }
// Order doesn't matter. Same result.
The practical difference shows up when your API evolves. Imagine adding an optional precision parameter to a math method:
Use named parameters for any API that might evolve, that has optional fields, or that is consumed by external clients. Use positional parameters only for simple, internal, performance-sensitive methods with a fixed argument count (like math.add([1, 2])).
Spec Rule
Params must be a Structured value — an Array or an Object. You cannot pass a primitive directly (no "params": 42). You cannot mix styles in a single call (no arrays with nested objects used as a positional-named hybrid).
Practical Pitfall: Data Types
JSON lacks native support for Dates and Big Integers.
- Dates: Use ISO 8601 strings (
"2024-02-14T12:00:00Z"). Never use integer timestamps if you want readability. - Big Integers: JavaScript (and many other languages) parse numbers as double-precision floats, which lose precision above
2^53 - 1. Always serialize 64-bit or 256-bit integers (like Ethereum balances) as strings or hex-strings to prevent data corruption.
JSON.parse('{"id": 9007199254740993}')
// { "id": 9007199254740992 } Corrupted!
1.4 id — the correlation key
An identifier set by the client. Its job is to match a response back to the request that created it. This might seem unnecessary if you're thinking in terms of HTTP (one request, one response, same connection), but JSON-RPC is transport-agnostic. Over WebSocket, a client might fire 50 requests in a second — responses arrive in any order. The id is the only thing that ties them together.
Allowed types: String, Number, or Null (though Null is discouraged — the spec reserves it for error responses where the request id couldn't be determined).
Numbers should not contain fractional parts because floating-point precision issues across languages make 1.0000000001 vs 1.0 comparison unreliable.
Common strategies for generating ids:
// Sequential integer — simple, fine for single-client scenarios
"id": 1
// UUID — guaranteed unique across distributed clients
"id": "550e8400-e29b-41d4-a716-446655440000"
// Prefixed timestamp — sortable, debuggable
"id": "req_1706745600_001"
Spec Rule: omit id → notification
If the id field is not included at all (not null — absent), the request becomes a Notification. The server MUST NOT respond. This is covered in the next section.
2. The Response Object
When the server receives a Request (not a Notification), it must reply with a Response. A Response has three members:
// Success:
{
"jsonrpc": "2.0",
"result": 19,
"id": 1
}
// Error:
{
"jsonrpc": "2.0",
"error": { "code": -32601, "message": "Method not found" },
"id": 1
}
2.1 The mutual exclusivity rule
This is one of the most important structural rule in the spec:
Spec Rule: result XOR error
Either result or error MUST be present, but both MUST NOT be present. They are mutually exclusive. A response with both fields is a protocol violation. A response with neither is also a violation.
This means your client parsing logic is straightforward:
function handleResponse(response) {
if (response.error) {
// Error path — error.code and error.message are guaranteed
throw new JsonRpcError(response.error.code, response.error.message, response.error.data);
}
// Success path — result exists (can be any JSON type, including null)
return response.result;
}
2.2 result — any valid JSON value
On success, the result field contains whatever the method returns. It can be any JSON type: an object, an array, a string, a number, a boolean, or null:
// This is a SUCCESSFUL response. The method returned null.
{
"jsonrpc": "2.0",
"result": null,
"id": 1
}
A delete operation that returns nothing, a void function, a lookup that found no records — all of these can legitimately return null. Don't confuse a null result (success) with a missing result (check for the error field).
2.3 id — must match the request
The response id must be the exact same value (same type, same content) as the request id. If the server couldn't determine the request id (because the JSON was unparseable or the request was structurally invalid), it must be null.
3. Notifications — The Fire-and-Forget Pattern
A Notification is a Request without an id field. The field isn't set to null — it's entirely absent from the JSON object.
// This is a Notification. No "id" field.
{
"jsonrpc": "2.0",
"method": "log.event",
"params": { "level": "info", "message": "User logged in" }
}
The spec is clear about what happens next: the server MUST NOT reply. Not on success, not on failure. The client sends this message into the void and moves on.
This is a protocol-level semantic, not a coding accident. The spec explicitly states: "Notifications are not confirmable by definition, since they do not have a Response object to be returned. As such, the Client would not be aware of any errors."
3.1 When to use notifications
Notifications are right when the client genuinely doesn't need confirmation. The canonical examples: logging events, telemetry/metrics collection, cache invalidation hints, real-time status heartbeats, and event broadcasts over WebSocket.
The Language Server Protocol uses notifications for a critical purpose: every time you type a character in VSCode, a textDocument/didChange notification fires to the language server.
3.2 When NOT to use notifications
Any operation where failure matters. If you need to know a write succeeded, a payment processed, or a user was created — use a regular request with an id. Notifications offer no error feedback. If the method doesn't exist, the params are wrong, or the server crashes mid-execution, the client will never know.
4. The Error Object
When a request fails, the response contains an error object (instead of result) with three members:
{
"jsonrpc": "2.0",
"error": {
"code": -32601, // Required. Integer.
"message": "Method not found", // Required. Short human-readable string.
"data": { ... } // Optional. Any type. Additional context.
},
"id": 1
}
4.1 Standard error codes
The spec reserves the range -32768 to -32000 for pre-defined errors. Six are explicitly defined:
| Code | Message | Meaning |
|---|---|---|
-32700 |
Parse error | Invalid JSON. The server couldn't even read the message. Response id will be null because the request couldn't be parsed. |
-32600 |
Invalid Request | Valid JSON, but not a valid JSON-RPC request. Missing "jsonrpc" or "method" field. |
-32601 |
Method not found | The method string doesn't match anything on the server. Closest analogy to HTTP 404. |
-32602 |
Invalid params | The method exists, but the arguments are wrong — wrong types, missing required fields, wrong count. |
-32603 |
Internal error | Catch-all for unhandled server exceptions. The JSON-RPC equivalent of HTTP 500. |
-32000 to -32099 |
Server error | Reserved for implementation-defined server errors. Frameworks can define their own codes here. |
Everything outside the -32768 to -32000 range is available for your application. Common conventions: use positive integers like 100 for auth failures, 101 for business logic errors, 102 for rate limiting.
4.2 The data field
Optional, but extremely useful. This is where you put structured error context — which field failed validation, what the expected format was, a correlation id for your logs.
{
"jsonrpc": "2.0",
"error": {
"code": -32602,
"message": "Invalid params",
"data": {
"field": "email",
"expected": "valid email format",
"received": "not-an-email"
}
},
"id": 1
}
4.3 Interactive: Message Builder
Build a JSON-RPC 2.0 request live. Change the method, toggle between named and positional params, toggle the id on/off to switch between Request and Notification mode. Hit "Send" to dispatch it to an in-browser mock server and see the real response (or silence).
4.4 Interactive: Error Code Explorer
Click each error code to see a request that triggers it and the exact response the server must return per spec. Notice how -32700 Parse error always has "id": null because the request couldn't be read.
5. Batch Requests — A Preview
The spec allows sending multiple requests as a JSON array. The server processes all of them and returns an array of responses:
// → Client sends batch (3 requests + 1 notification):
[
{"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
{"jsonrpc": "2.0", "method": "notify_hello", "params": [7]},
{"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"},
{"jsonrpc": "2.0", "method": "get_data", "id": "9"}
]
// ← Server responds (no response for the notification):
[
{"jsonrpc": "2.0", "result": 7, "id": "1"},
{"jsonrpc": "2.0", "result": 19, "id": "2"},
{"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"}
]
Key things to note: the response array has 3 items, not 4, because notify_hello was a notification (no id). Responses may arrive in any order — the client must match by id. A batch is not a transaction: individual items can succeed or fail independently.
We'll build a full batch implementation from scratch in Article 3.
6. Quick Reference
The complete set of rules for a valid JSON-RPC 2.0 exchange:
| Rule | Detail |
|---|---|
jsonrpc |
Must be "2.0" in every Request and every Response. |
method |
String. Case-sensitive. rpc. prefix reserved. |
params |
Array (positional) OR Object (named). May be omitted. |
id present |
It's a Request. Server MUST respond. |
id absent |
It's a Notification. Server MUST NOT respond. |
Response: result |
Present on success. Any JSON type including null. |
Response: error |
Present on failure. Has code (int), message (string), optional data. |
| Mutual exclusivity | Response has result XOR error. Never both. Never neither. |
Response id |
Must match request id. null only for parse errors / unknown request id. |
Error codes -32768 to -32000 |
Reserved for protocol. Don't use for application errors. |
| Batch | Array of requests → Array of responses. Notifications excluded from response. Not atomic. |