Articles 1 and 2 covered the protocol's message format and transport options. This article makes it concrete. We're going to build a simple JSON-RPC 2.0 server and client over HTTP protocol — in both JavaScript and Python — from zero, that handles validation, error codes, notifications, and batch requests.

The JavaScript version uses Express; the Python version uses FastAPI. Both are minimal HTTP frameworks that let us focus on JSON-RPC logic. The JSON-RPC logic is structurally identical; only the syntax differs.

The goal isn't a production-ready framework. It's to understand the spec deeply enough that you can audit any JSON-RPC implementation, spot where it deviates, and know why that matters.

1. The Server — Step by Step

A JSON-RPC server does five things, in this order: parse the raw input, validate the request structure, look up the method, execute it, and build the response. Each step has a specific error code if it fails.

Server processing pipeline. Each step has a specific error code. Parameter validation happens both structurally (before lookup) and semantically (during execution).
Server processing pipeline. Each step has a specific error code. Parameter validation happens both structurally (before lookup) and semantically (during execution).

1Parse JSON → -32700 Parse error

The first thing the server does is try to parse the raw input as JSON. If the input is not valid JSON, the server can't extract any fields — not even the id. So the error response must have "id": null.

To handle this properly, we need the raw request body as a string, not pre-parsed by the framework. In Express, use express.text() instead of express.json(). In FastAPI, use await request.body(). This gives us full control over the parse error response.

function handleRaw(raw) {
  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch (e) {
    return {
      jsonrpc: '2.0',
      error: { code: -32700, message: 'Parse error' },
      id: null  // Can't determine request id
    };
  }

  // Check for batch (Array) vs single (Object)
  if (Array.isArray(parsed)) return handleBatch(parsed);
  return handleSingle(parsed);
}
import json

def handle_raw(raw: bytes):
    try:
        parsed = json.loads(raw)
    except (json.JSONDecodeError, TypeError):
        return {
            "jsonrpc": "2.0",
            "error": {"code": -32700, "message": "Parse error"},
            "id": None,  # Can't determine request id
        }

    # Check for batch (list) vs single (dict)
    if isinstance(parsed, list):
        return handle_batch(parsed)
    return handle_single(parsed)

This is the only error that uses "id": null — because the request literally could not be read.

2Validate structure → -32600 Invalid Request

The JSON parsed, but is it a valid JSON-RPC 2.0 request? We check the "envelope": it must be an object, jsonrpc must be exactly "2.0", and method must be a string. You can also find Kotlin client model here.

function handleSingle(req) {
  const isNotif = req.id === undefined;

  if (typeof req !== 'object' || req === null
      || req.jsonrpc !== '2.0'
      || typeof req.method !== 'string') {
    return isNotif ? null : {
      jsonrpc: '2.0',
      error: { code: -32600, message: 'Invalid Request' },
      id: req?.id ?? null
    };
  }
  // ...
}
def handle_single(req):
    is_notif = "id" not in req

    if (not isinstance(req, dict)
        or req.get("jsonrpc") != "2.0"
        or not isinstance(req.get("method"), str)):
        return None if is_notif else {
            "jsonrpc": "2.0",
            "error": {"code": -32600, "message": "Invalid Request"},
            "id": req.get("id") if isinstance(req, dict) else None,
        }
    # ...

Note how notification detection differs: in JS we check req.id === undefined (the key is absent from the object), in Python we check "id" not in req (the key doesn't exist in the dict). Both mean the same thing: the client deliberately omitted the field.

3Method lookup → -32601 Method not found

We check if the requested method exists in our methods registry. If not, we return -32601. If the request is a notification, we stay silent.

const methods = {
  'math.add':      (p) => (Array.isArray(p) ? p[0]+p[1] : p.a+p.b),
  'math.subtract': (p) => (Array.isArray(p) ? p[0]-p[1] : p.minuend-p.subtrahend),
};

// Inside handleSingle, after Step 2:
const fn = methods[req.method];
if (!fn) {
  if (isNotif) return null;
  return {
    jsonrpc: '2.0',
    error: { code: -32601, message: 'Method not found' },
    id: req.id
  };
}
methods = {
    "math.add":      lambda p: (p[0]+p[1] if isinstance(p, list) else p["a"]+p["b"]),
    "math.subtract": lambda p: (p[0]-p[1] if isinstance(p, list) else p["minuend"]-p["subtrahend"]),
}

# Inside handle_single, after Step 2:
fn = methods.get(req["method"])
if fn is None:
    if is_notif: return None
    return {
        "jsonrpc": "2.0",
        "error": {"code": -32601, "message": "Method not found"},
        "id": req["id"],
    }

If the request has no id, we return null/None (meaning "no response"). The server must stay silent even when a notification references a method that doesn't exist.

4Validate parameters → -32602 Invalid params

Validation happens in two stages. First, structural validation: the spec requires params to be an Array or Object. Second, semantic validation: the method itself checks if the data is correct. If a method expects two numbers but gets a string, it should trigger -32602.

// 1. Structural check inside handleSingle
if (req.params !== undefined && (typeof req.params !== 'object' || req.params === null)) {
  return isNotif ? null : {
    jsonrpc: '2.0',
    error: { code: -32602, message: 'Invalid params' },
    id: req.id
  };
}

// 2. Semantic check (handled by catch in the next step)
# 1. Structural check inside handle_single
params = req.get("params")
if params is not None and not isinstance(params, (list, dict)):
    return None if is_notif else {
        "jsonrpc": "2.0",
        "error": {"code": -32602, "message": "Invalid params"},
        "id": req.get("id"),
    }

# 2. Semantic check (handled by except in the next step)

5Execute method → -32603 Internal error

Finally, we run the function. If it throws a TypeError or KeyError, it usually means the method code couldn't access parameters correctly — we map this to -32602. Any other error is a server-side crash (-32603).

try {
  const result = fn(req.params);
  if (isNotif) return null;
  return { jsonrpc: '2.0', result, id: req.id };
} catch (e) {
  if (isNotif) return null;
  const isParamErr = e instanceof TypeError;
  return {
    jsonrpc: '2.0',
    error: { 
      code: isParamErr ? -32602 : -32603, 
      message: isParamErr ? 'Invalid params' : 'Internal error' 
    },
    id: req.id
  };
}
try:
    result = fn(req.get("params"))
    if is_notif:
        return None
    return {"jsonrpc": "2.0", "result": result, "id": req["id"]}
except (TypeError, KeyError):
    if is_notif: return None
    return {
        "jsonrpc": "2.0",
        "error": {"code": -32602, "message": "Invalid params"},
        "id": req["id"],
    }
except Exception:
    if is_notif: return None
    return {
        "jsonrpc": "2.0",
        "error": {"code": -32603, "message": "Internal error"},
        "id": req["id"],
    }

6Batch processing

function handleBatch(arr) {
  if (arr.length === 0) {
    // Spec §6: empty array is invalid
    return { jsonrpc:'2.0', error:{code:-32600,message:'Invalid Request'}, id:null };
  }

  const responses = arr.map(handleSingle).filter(r => r !== null);
  return responses.length === 0 ? null : responses;
}
def handle_batch(arr: list):
    if len(arr) == 0:
        # Spec §6: empty array is invalid
        return {"jsonrpc":"2.0", "error":{"code":-32600,"message":"Invalid Request"}, "id":None}

    responses = [r for r in (handle_single(item) for item in arr) if r is not None]
    return None if len(responses) == 0 else responses

Two edge cases: an empty array [] is an Invalid Request, and if every item is a notification, the server returns nothing at all — not even an empty array.

The complete server

Here's the full thing wired up with the HTTP framework. Both versions read the raw body for proper -32700 handling:

const express = require('express');
const app = express();

// Read raw body — we parse JSON ourselves for proper -32700
app.use(express.text({ type: 'application/json' }));

const methods = {
  'math.add':      (p) => (Array.isArray(p) ? p[0]+p[1] : p.a+p.b),
  'math.subtract': (p) => (Array.isArray(p) ? p[0]-p[1] : p.minuend-p.subtrahend),
  'system.time':   ()  => new Date().toISOString(),
};

function handleSingle(req) {
  const isNotif = req.id === undefined;

  // Step 2: Structural validation (-32600)
  if (typeof req !== 'object' || req === null || req.jsonrpc !== '2.0' || typeof req.method !== 'string')
    return isNotif ? null : { jsonrpc:'2.0', error:{code:-32600,message:'Invalid Request'}, id:req?.id ?? null };
  
  // Step 3: Method lookup (-32601)
  const fn = methods[req.method];
  if (!fn) return isNotif ? null : { jsonrpc:'2.0', error:{code:-32601,message:'Method not found'}, id:req.id };

  // Step 4: Parameter validation (-32602)
  if (req.params !== undefined && (typeof req.params !== 'object' || req.params === null))
    return isNotif ? null : { jsonrpc:'2.0', error:{code:-32602,message:'Invalid params'}, id:req.id };

  // Step 5: Execution (-32603)
  try {
    const result = fn(req.params);
    return isNotif ? null : { jsonrpc:'2.0', result, id:req.id };
  } catch (e) {
    if (isNotif) return null;
    const isParamErr = e instanceof TypeError;
    return { jsonrpc:'2.0', error:{
      code: isParamErr ? -32602 : -32603,
      message: isParamErr ? 'Invalid params' : 'Internal error'
    }, id:req.id };
  }
}

function handleBatch(arr) {
  if (arr.length === 0) return { jsonrpc:'2.0', error:{code:-32600,message:'Invalid Request'}, id:null };
  const responses = arr.map(handleSingle).filter(r => r !== null);
  return responses.length === 0 ? null : responses;
}

app.post('/rpc', (req, res) => {
  // Step 1: Parse JSON ourselves
  let body;
  try { body = JSON.parse(req.body); }
  catch (e) { return res.json({ jsonrpc:'2.0', error:{code:-32700,message:'Parse error'}, id:null }); }

  const result = Array.isArray(body) ? handleBatch(body) : handleSingle(body);
  if (result === null) return res.end(); // Return 200 OK with no body for notifications
  res.json(result);
});

app.listen(3000, () => console.log('JSON-RPC server on :3000'));
import json
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response

app = FastAPI()

methods = {
    "math.add":      lambda p: (p[0]+p[1] if isinstance(p, list) else p["a"]+p["b"]),
    "math.subtract": lambda p: (p[0]-p[1] if isinstance(p, list) else p["minuend"]-p["subtrahend"]),
    "system.time":   lambda _=None: datetime.now(timezone.utc).isoformat(),
}

def handle_single(req):
    is_notif = "id" not in req

    # Step 2: Structural validation (-32600)
    if not isinstance(req, dict) or req.get("jsonrpc") != "2.0" or not isinstance(req.get("method"), str):
        return None if is_notif else {"jsonrpc":"2.0", "error":{"code":-32600,"message":"Invalid Request"},
                "id": req.get("id") if isinstance(req, dict) else None}
    
    # Step 3: Method lookup (-32601)
    fn = methods.get(req["method"])
    if fn is None:
        return None if is_notif else {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":req["id"]}

    # Step 4: Parameter validation (-32602)
    params = req.get("params")
    if params is not None and not isinstance(params, (list, dict)):
        return None if is_notif else {"jsonrpc":"2.0", "error":{"code":-32602,"message":"Invalid params"}, "id":req.get("id")}

    # Step 5: Execution (-32603)
    try:
        result = fn(params)
        return None if is_notif else {"jsonrpc": "2.0", "result": result, "id": req["id"]}
    except (TypeError, KeyError):
        return None if is_notif else {"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":req["id"]}
    except Exception:
        return None if is_notif else {"jsonrpc":"2.0","error":{"code":-32603,"message":"Internal error"},"id":req["id"]}

def handle_batch(arr):
    if len(arr) == 0:
        return {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request"},"id":None}
    responses = [r for r in (handle_single(item) for item in arr) if r is not None]
    return None if len(responses) == 0 else responses

@app.post("/rpc")
async def rpc_endpoint(request: Request):
    # Step 1: Read raw body and parse ourselves
    raw = await request.body()
    try:
        body = json.loads(raw)
    except (json.JSONDecodeError, TypeError):
        return JSONResponse({"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":None})

    result = handle_batch(body) if isinstance(body, list) else handle_single(body)
    if result is None:
        return Response()  # Default is 200 OK with empty body
    return JSONResponse(result)

# Run: uvicorn server:app --port 3000

You can verify your server is running correctly with these curl commands:

# 1. Valid request
curl -s -X POST http://localhost:3000/rpc \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"math.add","params":{"a":5,"b":3},"id":1}'

# 2. Trigger -32700 Parse error (invalid JSON)
curl -s -X POST http://localhost:3000/rpc \
  -H "Content-Type: application/json" \
  -d '{this is not json}'

# 3. Notification — server returns 200 OK with empty body
curl -s -o /dev/null -w "HTTP %{http_code}" -X POST http://localhost:3000/rpc \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"system.time"}'

2. The Client

A JSON-RPC client needs three methods: call (send request, get result or throw), notify (fire-and-forget), and batch (send multiple, correlate responses).

class JsonRpcClient {
  constructor(url) { this.url = url; this.nextId = 1; }

  async call(method, params) {
    const id = this.nextId++;
    const res = await fetch(this.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', method, params, id }),
    });
    const data = await res.json();
    if (data.error) {
      const err = new Error(data.error.message);
      err.code = data.error.code;
      err.data = data.error.data;
      throw err;
    }
    return data.result;
  }

  async notify(method, params) {
    await fetch(this.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', method, params }),
    }); // No id, don't read response
  }

  async batch(calls) {
    const requests = calls.map(({ method, params, notify }) => {
      const obj = { jsonrpc: '2.0', method, params };
      if (!notify) obj.id = this.nextId++;
      return obj;
    });
    const res = await fetch(this.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(requests),
    });
    const text = await res.text();
    if (!text) return []; // Handle empty body (all notifications)
    return JSON.parse(text);
  }
}
import httpx

class JsonRpcClient:
    def __init__(self, url: str):
        self.url = url
        self.next_id = 1
        self.http = httpx.Client()

    def call(self, method: str, params=None):
        id = self.next_id; self.next_id += 1
        res = self.http.post(self.url, json={
            "jsonrpc": "2.0", "method": method,
            "params": params, "id": id,
        })
        data = res.json()
        if "error" in data:
            raise JsonRpcError(data["error"])
        return data["result"]

    def notify(self, method: str, params=None):
        self.http.post(self.url, json={
            "jsonrpc": "2.0", "method": method, "params": params,
        })  # No id, ignore response

    def batch(self, calls: list):
        requests = []
        for c in calls:
            obj = {"jsonrpc": "2.0", "method": c["method"], "params": c.get("params")}
            if not c.get("notify"):
                obj["id"] = self.next_id; self.next_id += 1
            requests.append(obj)
        res = self.http.post(self.url, json=requests)
        if not res.content:
            return []
        return res.json()

class JsonRpcError(Exception):
    def __init__(self, error: dict):
        self.code = error["code"]
        self.data = error.get("data")
        super().__init__(error["message"])

The Python client uses httpx (sync). You can also use httpx.AsyncClient for async, or plain requests if you prefer — the JSON-RPC logic is identical.

Real-world client implementation in Kotlin can be found here for WalletConnect protocol implementation.

Batch ≠ Transaction

A batch is a performance optimization (one HTTP round-trip instead of N), not a transaction. Item 1 can succeed while item 3 fails. The server may process them in parallel. If you need atomicity, you need application-level transaction logic.

3. Adding Middleware

The server above works, but a production system needs cross-cutting concerns: authentication, validation, logging, and rate limiting. Both Express and FastAPI have middleware patterns — here's how to apply them to JSON-RPC.

3.1 Authentication: tokens in headers

Authentication can be handled in several ways depending on your transport. While HTTP headers are common for standard web APIs, you might also use query parameters, WebSocket subprotocols, or even a dedicated auth.login RPC method as discussed in Article 2. Putting tokens inside params is another standard approach that makes the message fully transport-agnostic, though it requires more care to avoid leaking secrets in server logs.

We noted in Article 1 that the JSON-RPC spec itself has no headers. However, when running over HTTP, we can utilize the transport's header layer for authentication.

function authMiddleware(req, res, next) {
  const token = req.headers.authorization;
  if (!token || !token.startsWith('Bearer ')) {
    return res.json({
      jsonrpc: '2.0',
      error: { code: -32000, message: 'Authentication required' },
      id: null
    });
  }
  req.user = validateToken(token.slice(7));
  next();
}

// Apply: app.use('/rpc', authMiddleware);
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware

class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        auth = request.headers.get("authorization", "")
        if not auth.startswith("Bearer "):
            return JSONResponse({
                "jsonrpc": "2.0",
                "error": {"code": -32000, "message": "Authentication required"},
                "id": None,
            })
        request.state.user = validate_token(auth[7:])
        return await call_next(request)

# Apply: app.add_middleware(AuthMiddleware)

The error code -32000 is in the implementation-defined range (-32000 to -32099). The spec reserves this range for exactly this purpose.

3.2 CORS: The forgotten hurdle

If you're calling this API from a browser frontend (React, Vue), you'll hit CORS errors immediately. Here's the fix:

const cors = require('cors');
app.use(cors()); // Allow all origins
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

3.3 Rate limiting: the batch complication

Standard HTTP rate limiters count HTTP requests. But one JSON-RPC HTTP request can contain a batch of 100 RPC calls. An attacker sends 1 HTTP request per second (passes the rate limiter) containing 10,000 batch items (overwhelms the server).

The fix: rate-limit at the JSON-RPC layer, counting individual method calls, not HTTP requests. And enforce a hard batch size cap (simple implementation without time-based limit):

const MAX_BATCH_SIZE = 50;

app.post('/rpc', (req, res) => {
  let body;
  try { body = JSON.parse(req.body); }
  catch { return res.json({jsonrpc:'2.0',error:{code:-32700,message:'Parse error'},id:null}); }

  // Enforce batch size limit
  if (Array.isArray(body) && body.length > MAX_BATCH_SIZE) {
    return res.json({
      jsonrpc: '2.0',
      error: { code: -32600, message: `Batch too large (max ${MAX_BATCH_SIZE})` },
      id: null
    });
  }

  // ... rest of handling
});
MAX_BATCH_SIZE = 50

@app.post("/rpc")
async def rpc_endpoint(request: Request):
    raw = await request.body()
    try:
        body = json.loads(raw)
    except (json.JSONDecodeError, TypeError):
        return JSONResponse({"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":None})

    # Enforce batch size limit
    if isinstance(body, list) and len(body) > MAX_BATCH_SIZE:
        return JSONResponse({
            "jsonrpc": "2.0",
            "error": {"code": -32600, "message": f"Batch too large (max {MAX_BATCH_SIZE})"},
            "id": None,
        })

    # ... rest of handling

Alchemy, for example, caps batches at 1000 items for HTTP but strictly limits them to 20 for WebSockets. Pick a limit appropriate for your use case and transport, and enforce it before processing.

4. What We Built — and What's Left

Our server correctly handles: all five spec error codes (-32700 through -32603), notifications (silent execution), batch requests (including empty-array and all-notifications edge cases), proper raw-body parsing for parse errors, and the id correlation contract.

What production systems still need: concurrent batch execution (Promise.allSettled / asyncio.gather instead of sequential processing), Content-Type enforcement (reject non-application/json for CSRF defense — see Article 2), and request timeouts (wrap execution in a timeout to prevent hung methods).